1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2025-12-07 02:28:54 +08:00

9 Commits

Author SHA1 Message Date
lana-k
378b9fb580 #113 upgrade plotly 2024-09-23 16:46:50 +02:00
lana-k
244ba9eb08 #116 add JSON/NDJSON 2024-09-17 11:35:53 +02:00
lana-k
53e5194295 #116 update tests 2024-09-16 23:49:02 +02:00
lana-k
04274ef19a #116 fix lint 2024-09-15 18:08:46 +02:00
lana-k
3893a66f4e Merge branch 'master' of github.com:lana-k/sqliteviz 2024-09-05 22:15:38 +02:00
lana-k
1b6b7c71e9 #116 JSON file import 2024-09-05 22:15:12 +02:00
saaj
3f6427ff0e Build sqlitelua for scalar, aggregate & table-valued UDFs in Lua (#118)
* Update base Docker images

* Use performance.now() instead of Date.now() for time promise tests

* Build sqlitelua: user scalar, aggregate & table-valued functions in Lua
2024-08-25 21:03:34 +02:00
lana-k
a2464d839f #115 fix version number 2024-01-07 13:55:38 +01:00
lana-k
316e603c3c #115 style fixes 2024-01-07 13:37:21 +01:00
36 changed files with 4223 additions and 4319 deletions

View File

@@ -14,10 +14,10 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 10.x node-version: 16.x
- name: Update npm - name: Update npm
run: npm install -g npm@7 run: npm install -g npm@8
- name: npm install and build - name: npm install and build
run: | run: |

View File

@@ -17,7 +17,7 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 10.x node-version: 16.x
- name: Install browsers - name: Install browsers
run: | run: |
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
@@ -25,7 +25,7 @@ jobs:
sudo apt-get install -y chromium-browser firefox sudo apt-get install -y chromium-browser firefox
- name: Update npm - name: Update npm
run: npm install -g npm@7 run: npm install -g npm@8
- name: Install the project - name: Install the project
run: npm install run: npm install

View File

@@ -3,7 +3,7 @@
# docker build -t sqliteviz/test -f Dockerfile.test . # docker build -t sqliteviz/test -f Dockerfile.test .
# #
FROM node:12 FROM node:12.22-buster
RUN set -ex; \ RUN set -ex; \
apt update; \ apt update; \

View File

@@ -4,11 +4,12 @@
# sqliteviz # sqliteviz
Sqliteviz is a single-page offline-first PWA for fully client-side visualisation of SQLite databases or CSV files. Sqliteviz is a single-page offline-first PWA for fully client-side visualisation
of SQLite databases, CSV, JSON or NDJSON files.
With sqliteviz you can: With sqliteviz you can:
- run SQL queries against a SQLite database and create [Plotly][11] charts and pivot tables based on the result sets - run SQL queries against a SQLite database and create [Plotly][11] charts and pivot tables based on the result sets
- import a CSV file into a SQLite database and visualize imported data - import a CSV/JSON/NDJSON file into a SQLite database and visualize imported data
- export result set to CSV file - export result set to CSV file
- manage inquiries and run them against different databases - manage inquiries and run them against different databases
- import/export inquiries from/to a JSON file - import/export inquiries from/to a JSON file

View File

@@ -45,6 +45,8 @@ SQLite 3rd party extensions included:
1. [pivot_vtab][5] -- a pivot virtual table 1. [pivot_vtab][5] -- a pivot virtual table
2. `pearson` correlation coefficient function extension from [sqlean][21] 2. `pearson` correlation coefficient function extension from [sqlean][21]
(which is part of [squib][20]) (which is part of [squib][20])
3. [sqlitelua][22] -- a virtual table `luafunctions` which allows to define custom scalar,
aggregate and table-valued functions in Lua
To ease the step to have working clone locally, the build is committed into To ease the step to have working clone locally, the build is committed into
the repository. the repository.
@@ -103,3 +105,4 @@ described in [this message from SQLite Forum][12]:
[19]: https://github.com/lana-k/sqliteviz/blob/master/tests/lib/database/sqliteExtensions.spec.js [19]: https://github.com/lana-k/sqliteviz/blob/master/tests/lib/database/sqliteExtensions.spec.js
[20]: https://github.com/mrwilson/squib/blob/master/pearson.c [20]: https://github.com/mrwilson/squib/blob/master/pearson.c
[21]: https://github.com/nalgeon/sqlean/blob/incubator/src/pearson.c [21]: https://github.com/nalgeon/sqlean/blob/incubator/src/pearson.c
[22]: https://github.com/kev82/sqlitelua

View File

@@ -1,10 +1,8 @@
FROM node:14-bullseye FROM node:20.14-bookworm
RUN set -ex; \ RUN set -ex; \
echo 'deb http://deb.debian.org/debian unstable main' \
> /etc/apt/sources.list.d/unstable.list; \
apt-get update; \ apt-get update; \
apt-get install -y -t unstable firefox; \ apt-get install -y firefox-esr; \
apt-get install -y chromium apt-get install -y chromium
WORKDIR /tmp/build WORKDIR /tmp/build

View File

@@ -69,6 +69,19 @@
], ],
"metadata": {} "metadata": {}
}, },
{
"cell_type": "code",
"source": [
"!du -b lib | head -n 2"
],
"outputs": [],
"execution_count": null,
"metadata": {
"collapsed": false,
"outputHidden": false,
"inputHidden": true
}
},
{ {
"cell_type": "code", "cell_type": "code",
"source": [ "source": [
@@ -176,7 +189,7 @@
}, },
"language_info": { "language_info": {
"name": "python", "name": "python",
"version": "3.10.7", "version": "3.10.14",
"mimetype": "text/x-python", "mimetype": "text/x-python",
"codemirror_mode": { "codemirror_mode": {
"name": "ipython", "name": "ipython",

View File

@@ -24,6 +24,7 @@ cflags = (
# Compile-time optimisation # Compile-time optimisation
'-Os', # reduces the code size about in half comparing to -O2 '-Os', # reduces the code size about in half comparing to -O2
'-flto', '-flto',
'-Isrc', '-Isrc/lua',
) )
emflags = ( emflags = (
# Base # Base
@@ -61,6 +62,15 @@ def build(src: Path, dst: Path):
'-c', src / 'extension-functions.c', '-c', src / 'extension-functions.c',
'-o', out / 'extension-functions.o', '-o', out / 'extension-functions.o',
]) ])
logging.info('Building LLVM bitcode for SQLite Lua extension')
subprocess.check_call([
'emcc',
*cflags,
'-shared',
*(src / 'lua').glob('*.c'),
*(src / 'sqlitelua').glob('*.c'),
'-o', out / 'sqlitelua.o',
])
logging.info('Building WASM from bitcode') logging.info('Building WASM from bitcode')
subprocess.check_call([ subprocess.check_call([
@@ -68,6 +78,7 @@ def build(src: Path, dst: Path):
*emflags, *emflags,
out / 'sqlite3.o', out / 'sqlite3.o',
out / 'extension-functions.o', out / 'extension-functions.o',
out / 'sqlitelua.o',
'-o', out / 'sql-wasm.js', '-o', out / 'sql-wasm.js',
]) ])

View File

@@ -1,7 +1,9 @@
import logging import logging
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tarfile
import zipfile import zipfile
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@@ -30,8 +32,14 @@ extension_urls = (
# ===================== # =====================
('https://github.com/jakethaw/pivot_vtab/raw/9323ef93/pivot_vtab.c', 'sqlite3_pivotvtab_init'), ('https://github.com/jakethaw/pivot_vtab/raw/9323ef93/pivot_vtab.c', 'sqlite3_pivotvtab_init'),
('https://github.com/nalgeon/sqlean/raw/95e8d21a/src/pearson.c', 'sqlite3_pearson_init'), ('https://github.com/nalgeon/sqlean/raw/95e8d21a/src/pearson.c', 'sqlite3_pearson_init'),
# Third-party extension with own dependencies
# ===========================================
('https://github.com/kev82/sqlitelua/raw/db479510/src/main.c', 'sqlite3_luafunctions_init'),
) )
lua_url = 'http://www.lua.org/ftp/lua-5.3.5.tar.gz'
sqlitelua_url = 'https://github.com/kev82/sqlitelua/archive/db479510.zip'
sqljs_url = 'https://github.com/sql-js/sql.js/archive/refs/tags/v1.7.0.zip' sqljs_url = 'https://github.com/sql-js/sql.js/archive/refs/tags/v1.7.0.zip'
@@ -59,6 +67,38 @@ def _get_amalgamation(tgt: Path):
shutil.copyfileobj(fr, fw) shutil.copyfileobj(fr, fw)
def _get_lua(tgt: Path):
# Library definitions from lua/Makefile
lib_str = '''
CORE_O= lapi.o lcode.o lctype.o ldebug.o ldo.o ldump.o lfunc.o lgc.o llex.o \
lmem.o lobject.o lopcodes.o lparser.o lstate.o lstring.o ltable.o \
ltm.o lundump.o lvm.o lzio.o
LIB_O= lauxlib.o lbaselib.o lbitlib.o lcorolib.o ldblib.o liolib.o \
lmathlib.o loslib.o lstrlib.o ltablib.o lutf8lib.o loadlib.o linit.o
LUA_O= lua.o
'''
header_only_files = {'lprefix', 'luaconf', 'llimits', 'lualib'}
lib_names = set(re.findall(r'(\w+)\.o', lib_str)) | header_only_files
logging.info('Downloading and extracting Lua %s', lua_url)
archive = tarfile.open(fileobj=BytesIO(request.urlopen(lua_url).read()))
(tgt / 'lua').mkdir()
for tarinfo in archive:
tarpath = Path(tarinfo.name)
if tarpath.match('src/*') and tarpath.stem in lib_names:
with (tgt / 'lua' / tarpath.name).open('wb') as fw:
shutil.copyfileobj(archive.extractfile(tarinfo), fw)
logging.info('Downloading and extracting SQLite Lua extension %s', sqlitelua_url)
archive = zipfile.ZipFile(BytesIO(request.urlopen(sqlitelua_url).read()))
archive_root_dir = zipfile.Path(archive, archive.namelist()[0])
(tgt / 'sqlitelua').mkdir()
for zpath in (archive_root_dir / 'src').iterdir():
if zpath.name != 'main.c':
with zpath.open() as fr, (tgt / 'sqlitelua' / zpath.name).open('wb') as fw:
shutil.copyfileobj(fr, fw)
def _get_contrib_functions(tgt: Path): def _get_contrib_functions(tgt: Path):
request.urlretrieve(contrib_functions_url, tgt / 'extension-functions.c') request.urlretrieve(contrib_functions_url, tgt / 'extension-functions.c')
@@ -70,6 +110,7 @@ def _get_extensions(tgt: Path):
for url, init_fn in extension_urls: for url, init_fn in extension_urls:
logging.info('Downloading and appending to amalgamation %s', url) logging.info('Downloading and appending to amalgamation %s', url)
with request.urlopen(url) as resp: with request.urlopen(url) as resp:
f.write(b'\n')
shutil.copyfileobj(resp, f) shutil.copyfileobj(resp, f)
init_functions.append(init_fn) init_functions.append(init_fn)
@@ -90,6 +131,7 @@ def _get_sqljs(tgt: Path):
def configure(tgt: Path): def configure(tgt: Path):
_get_amalgamation(tgt) _get_amalgamation(tgt)
_get_contrib_functions(tgt) _get_contrib_functions(tgt)
_get_lua(tgt)
_get_extensions(tgt) _get_extensions(tgt)
_get_sqljs(tgt) _get_sqljs(tgt)

File diff suppressed because one or more lines are too long

Binary file not shown.

7069
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "sqliteviz", "name": "sqliteviz",
"version": "0.24.0", "version": "0.25.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -10,7 +10,7 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"codemirror": "^5.57.0", "codemirror": "^5.65.18",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"dataurl-to-blob": "^0.0.1", "dataurl-to-blob": "^0.0.1",
"html2canvas": "^1.1.4", "html2canvas": "^1.1.4",
@@ -18,11 +18,11 @@
"nanoid": "^3.1.12", "nanoid": "^3.1.12",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pivottable": "^2.23.0", "pivottable": "^2.23.0",
"plotly.js": "^1.58.4", "plotly.js": "^2.35.2",
"promise-worker": "^2.0.1", "promise-worker": "^2.0.1",
"react": "^16.13.1", "react": "^16.14.0",
"react-chart-editor": "^0.45.0", "react-chart-editor": "^0.46.1",
"react-dom": "^16.13.1", "react-dom": "^16.14.0",
"sql.js": "file:./lib/sql-js", "sql.js": "file:./lib/sql-js",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-codemirror": "^4.0.6", "vue-codemirror": "^4.0.6",

View File

@@ -1,6 +1,6 @@
{ {
"background_color": "white", "background_color": "white",
"description": "Sqliteviz is a single-page application for fully client-side visualisation of SQLite databases or CSV.", "description": "Sqliteviz is a single-page application for fully client-side visualisation of SQLite databases, CSV, JSON or NDJSON.",
"display": "fullscreen", "display": "fullscreen",
"icons": [ "icons": [
{ {

View File

@@ -8,8 +8,8 @@
:clickToClose="false" :clickToClose="false"
> >
<div class="dialog-header"> <div class="dialog-header">
CSV import {{ typeName }} import
<close-icon @click="cancelCsvImport" :disabled="disableDialog"/> <close-icon @click="cancelImport" :disabled="disableDialog"/>
</div> </div>
<div class="dialog-body"> <div class="dialog-body">
<text-field <text-field
@@ -18,15 +18,15 @@
width="484px" width="484px"
:disabled="disableDialog" :disabled="disableDialog"
:error-msg="tableNameError" :error-msg="tableNameError"
id="csv-table-name" id="csv-json-table-name"
/> />
<div class="chars"> <div v-if="!isJson && !isNdJson" class="chars">
<delimiter-selector <delimiter-selector
v-model="delimiter" v-model="delimiter"
width="210px" width="210px"
class="char-input" class="char-input"
@input="previewCsv"
:disabled="disableDialog" :disabled="disableDialog"
@input="preview"
/> />
<text-field <text-field
label="Quote char" label="Quote char"
@@ -36,6 +36,7 @@
:disabled="disableDialog" :disabled="disableDialog"
class="char-input" class="char-input"
id="quote-char" id="quote-char"
@input="preview"
/> />
<text-field <text-field
label="Escape char" label="Escape char"
@@ -49,52 +50,52 @@
:disabled="disableDialog" :disabled="disableDialog"
class="char-input" class="char-input"
id="escape-char" id="escape-char"
@input="preview"
/> />
</div> </div>
<check-box <check-box
@click="header = $event" v-if="!isJson && !isNdJson"
:init="true" :init="header"
label="Use first row as column headers" label="Use first row as column headers"
:disabled="disableDialog" :disabled="disableDialog"
@click="changeHeaderDisplaying"
/> />
<sql-table <sql-table
v-if="previewData v-if="previewData && previewData.rowCount > 0"
&& (previewData.rowCount > 0 || Object.keys(previewData).length > 0)
"
:data-set="previewData" :data-set="previewData"
class="preview-table"
:preview="true" :preview="true"
class="preview-table"
/> />
<div v-else class="no-data">No data</div> <div v-else class="no-data">No data</div>
<logs <logs
class="import-csv-errors" class="import-errors"
:messages="importCsvMessages" :messages="importMessages"
/> />
</div> </div>
<div class="dialog-buttons-container"> <div class="dialog-buttons-container">
<button <button
class="secondary" class="secondary"
:disabled="disableDialog" :disabled="disableDialog"
@click="cancelCsvImport" @click="cancelImport"
id="csv-cancel" id="import-cancel"
> >
Cancel Cancel
</button> </button>
<button <button
v-show="!importCsvCompleted" v-show="!importCompleted"
class="primary" class="primary"
:disabled="disableDialog" :disabled="disableDialog || disableImport"
@click="loadFromCsv(file)" @click="loadToDb(file)"
id="csv-import" id="import-start"
> >
Import Import
</button> </button>
<button <button
v-show="importCsvCompleted" v-show="importCompleted"
class="primary" class="primary"
:disabled="disableDialog" :disabled="disableDialog"
@click="finish" @click="finish"
id="csv-finish" id="import-finish"
> >
Finish Finish
</button> </button>
@@ -115,7 +116,7 @@ import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events' import events from '@/lib/utils/events'
export default { export default {
name: 'CsvImport', name: 'CsvJsonImport',
components: { components: {
CloseIcon, CloseIcon,
TextField, TextField,
@@ -124,33 +125,50 @@ export default {
SqlTable, SqlTable,
Logs Logs
}, },
props: ['file', 'db', 'dialogName'], props: {
file: File,
db: Object,
dialogName: String
},
data () { data () {
return { return {
disableDialog: false, disableDialog: false,
disableImport: false,
tableName: '', tableName: '',
delimiter: '', delimiter: '',
quoteChar: '"', quoteChar: '"',
escapeChar: '"', escapeChar: '"',
header: true, header: true,
importCsvCompleted: false, importCompleted: false,
importCsvMessages: [], importMessages: [],
previewData: null, previewData: null,
addedTable: null, addedTable: null,
tableNameError: '' tableNameError: ''
} }
}, },
computed: {
isJson () {
return fIo.isJSON(this.file)
},
isNdJson () {
return fIo.isNDJSON(this.file)
},
typeName () {
return this.isJson || this.isNdJson ? 'JSON' : 'CSV'
}
},
watch: { watch: {
quoteChar () { isJson () {
this.previewCsv() if (this.isJson) {
this.delimiter = '\u001E'
this.header = false
}
}, },
isNdJson () {
escapeChar () { if (this.isNdJson) {
this.previewCsv() this.delimiter = '\u001E'
}, this.header = false
}
header () {
this.previewCsv()
}, },
tableName: time.debounce(function () { tableName: time.debounce(function () {
this.tableNameError = '' this.tableNameError = ''
@@ -164,7 +182,11 @@ export default {
}, 400) }, 400)
}, },
methods: { methods: {
cancelCsvImport () { changeHeaderDisplaying (e) {
this.header = e
this.preview()
},
cancelImport () {
if (!this.disableDialog) { if (!this.disableDialog) {
if (this.addedTable) { if (this.addedTable) {
this.db.execute(`DROP TABLE "${this.addedTable}"`) this.db.execute(`DROP TABLE "${this.addedTable}"`)
@@ -175,14 +197,15 @@ export default {
} }
}, },
reset () { reset () {
this.header = true this.header = !this.isJson && !this.isNdJson
this.quoteChar = '"' this.quoteChar = '"'
this.escapeChar = '"' this.escapeChar = '"'
this.delimiter = '' this.delimiter = !this.isJson && !this.isNdJson ? '' : '\u001E'
this.tableName = '' this.tableName = ''
this.disableDialog = false this.disableDialog = false
this.importCsvCompleted = false this.disableImport = false
this.importCsvMessages = [] this.importCompleted = false
this.importMessages = []
this.previewData = null this.previewData = null
this.addedTable = null this.addedTable = null
this.tableNameError = '' this.tableNameError = ''
@@ -191,39 +214,69 @@ export default {
this.tableName = this.db.sanitizeTableName(fIo.getFileName(this.file)) this.tableName = this.db.sanitizeTableName(fIo.getFileName(this.file))
this.$modal.show(this.dialogName) this.$modal.show(this.dialogName)
}, },
async previewCsv () { async preview () {
this.importCsvCompleted = false this.disableImport = false
if (!this.file) {
return
}
this.importCompleted = false
const config = { const config = {
preview: 3, preview: 3,
quoteChar: this.quoteChar || '"', quoteChar: this.quoteChar || '"',
escapeChar: this.escapeChar, escapeChar: this.escapeChar,
header: this.header, header: this.header,
delimiter: this.delimiter delimiter: this.delimiter,
columns: !this.isJson && !this.isNdJson ? null : ['doc']
} }
try { try {
const start = new Date() const start = new Date()
const parseResult = await csv.parse(this.file, config) const parseResult = this.isJson
? await this.getJsonParseResult(this.file)
: await csv.parse(this.file, config)
const end = new Date() const end = new Date()
this.previewData = parseResult.data this.previewData = parseResult.data
this.previewData.rowCount = parseResult.rowCount
this.delimiter = parseResult.delimiter this.delimiter = parseResult.delimiter
// In parseResult.messages we can get parse errors // In parseResult.messages we can get parse errors
this.importCsvMessages = parseResult.messages || [] this.importMessages = parseResult.messages || []
if (this.previewData.rowCount === 0) {
this.disableImport = true
this.importMessages.push({
type: 'info',
message: 'No rows to import.'
})
}
if (!parseResult.hasErrors) { if (!parseResult.hasErrors) {
this.importCsvMessages.push({ this.importMessages.push({
message: `Preview parsing is completed in ${time.getPeriod(start, end)}.`, message: `Preview parsing is completed in ${time.getPeriod(start, end)}.`,
type: 'success' type: 'success'
}) })
} }
} catch (err) { } catch (err) {
this.importCsvMessages = [{ console.error(err)
this.importMessages = [{
message: err, message: err,
type: 'error' type: 'error'
}] }]
} }
}, },
async loadFromCsv (file) { async getJsonParseResult (file) {
const jsonContent = await fIo.getFileContent(file)
const isEmpty = !jsonContent.trim()
return {
data: {
columns: ['doc'],
values: { doc: !isEmpty ? [jsonContent] : [] }
},
hasErrors: false,
messages: [],
rowCount: +(!isEmpty)
}
},
async loadToDb (file) {
if (!this.tableName) { if (!this.tableName) {
this.tableNameError = "Table name can't be empty" this.tableNameError = "Table name can't be empty"
return return
@@ -234,17 +287,18 @@ export default {
quoteChar: this.quoteChar || '"', quoteChar: this.quoteChar || '"',
escapeChar: this.escapeChar, escapeChar: this.escapeChar,
header: this.header, header: this.header,
delimiter: this.delimiter delimiter: this.delimiter,
columns: !this.isJson && !this.isNdJson ? null : ['doc']
} }
const parseCsvMsg = { const parsingMsg = {
message: 'Parsing CSV...', message: `Parsing ${this.typeName}...`,
type: 'info' type: 'info'
} }
this.importCsvMessages.push(parseCsvMsg) this.importMessages.push(parsingMsg)
const parseCsvLoadingIndicator = setTimeout(() => { parseCsvMsg.type = 'loading' }, 1000) const parsingLoadingIndicator = setTimeout(() => { parsingMsg.type = 'loading' }, 1000)
const importMsg = { const importMsg = {
message: 'Importing CSV into a SQLite database...', message: `Importing ${this.typeName} into a SQLite database...`,
type: 'info' type: 'info'
} }
let importLoadingIndicator = null let importLoadingIndicator = null
@@ -256,27 +310,30 @@ export default {
try { try {
let start = new Date() let start = new Date()
const parseResult = await csv.parse(this.file, config) const parseResult = this.isJson
? await this.getJsonParseResult(file)
: await csv.parse(this.file, config)
let end = new Date() let end = new Date()
if (!parseResult.hasErrors) { if (!parseResult.hasErrors) {
const rowCount = parseResult.rowCount const rowCount = parseResult.rowCount
let period = time.getPeriod(start, end) let period = time.getPeriod(start, end)
parseCsvMsg.type = 'success' parsingMsg.type = 'success'
if (parseResult.messages.length > 0) { if (parseResult.messages.length > 0) {
this.importCsvMessages = this.importCsvMessages.concat(parseResult.messages) this.importMessages = this.importMessages.concat(parseResult.messages)
parseCsvMsg.message = `${rowCount} rows are parsed in ${period}.` parsingMsg.message = `${rowCount} rows are parsed in ${period}.`
} else { } else {
// Inform about csv parsing success // Inform about parsing success
parseCsvMsg.message = `${rowCount} rows are parsed successfully in ${period}.` parsingMsg.message = `${rowCount} rows are parsed successfully in ${period}.`
} }
// Loading indicator for csv parsing is not needed anymore // Loading indicator for parsing is not needed anymore
clearTimeout(parseCsvLoadingIndicator) clearTimeout(parsingLoadingIndicator)
// Add info about import start // Add info about import start
this.importCsvMessages.push(importMsg) this.importMessages.push(importMsg)
// Show import progress after 1 second // Show import progress after 1 second
importLoadingIndicator = setTimeout(() => { importLoadingIndicator = setTimeout(() => {
@@ -291,52 +348,108 @@ export default {
this.addedTable = this.tableName this.addedTable = this.tableName
// Inform about import success // Inform about import success
period = time.getPeriod(start, end) period = time.getPeriod(start, end)
importMsg.message = `Importing CSV into a SQLite database is completed in ${period}.` importMsg.message = `Importing ${this.typeName} ` +
`into a SQLite database is completed in ${period}.`
importMsg.type = 'success' importMsg.type = 'success'
// Loading indicator for import is not needed anymore // Loading indicator for import is not needed anymore
clearTimeout(importLoadingIndicator) clearTimeout(importLoadingIndicator)
this.importCsvCompleted = true this.importCompleted = true
} else { } else {
parseCsvMsg.message = 'Parsing ended with errors.' parsingMsg.message = 'Parsing ended with errors.'
parseCsvMsg.type = 'info' parsingMsg.type = 'info'
this.importCsvMessages = this.importCsvMessages.concat(parseResult.messages) this.importMessages = this.importMessages.concat(parseResult.messages)
} }
} catch (err) { } catch (err) {
if (parseCsvMsg.type === 'loading') { console.error(err)
parseCsvMsg.type = 'info' if (parsingMsg.type === 'loading') {
parsingMsg.type = 'info'
} }
if (importMsg.type === 'loading') { if (importMsg.type === 'loading') {
importMsg.type = 'info' importMsg.type = 'info'
} }
this.importCsvMessages.push({ this.importMessages.push({
message: err, message: err,
type: 'error' type: 'error'
}) })
} }
clearTimeout(parseCsvLoadingIndicator) clearTimeout(parsingLoadingIndicator)
clearTimeout(importLoadingIndicator) clearTimeout(importLoadingIndicator)
this.db.deleteProgressCounter(progressCounterId) this.db.deleteProgressCounter(progressCounterId)
this.disableDialog = false this.disableDialog = false
}, },
async finish () { async finish () {
this.$modal.hide(this.dialogName) this.$modal.hide(this.dialogName)
const stmt = [ const stmt = this.getQueryExample()
'/*', const tabId = await this.$store.dispatch('addTab', { query: stmt })
this.$store.commit('setCurrentTabId', tabId)
this.importCompleted = false
this.$emit('finish')
events.send('inquiry.create', null, { auto: true })
},
getQueryExample () {
return this.isNdJson ? this.getNdJsonQueryExample()
: this.isJson ? this.getJsonQueryExample()
: [
'/*',
` * Your CSV file has been imported into ${this.addedTable} table.`, ` * Your CSV file has been imported into ${this.addedTable} table.`,
' * You can run this SQL query to make all CSV records available for charting.', ' * You can run this SQL query to make all CSV records available for charting.',
' */', ' */',
`SELECT * FROM "${this.addedTable}"` `SELECT * FROM "${this.addedTable}"`
].join('\n') ].join('\n')
const tabId = await this.$store.dispatch('addTab', { query: stmt }) },
this.$store.commit('setCurrentTabId', tabId) getNdJsonQueryExample () {
this.importCsvCompleted = false try {
this.$emit('finish') const firstRowJson = JSON.parse(this.previewData.values.doc[0])
events.send('inquiry.create', null, { auto: true }) const firstKey = Object.keys(firstRowJson)[0]
return [
'/*',
` * Your NDJSON file has been imported into ${this.addedTable} table.`,
` * Run this SQL query to get values of property ${firstKey} ` +
'and make them available for charting.',
' */',
`SELECT doc->>'${firstKey}'`,
`FROM "${this.addedTable}"`
].join('\n')
} catch (err) {
console.error(err)
return [
'/*',
` * Your NDJSON file has been imported into ${this.addedTable} table.`,
' */',
'SELECT *',
`FROM "${this.addedTable}"`
].join('\n')
}
},
getJsonQueryExample () {
try {
const firstRowJson = JSON.parse(this.previewData.values.doc[0])
const firstKey = Object.keys(firstRowJson)[0]
return [
'/*',
` * Your JSON file has been imported into ${this.addedTable} table.`,
` * Run this SQL query to get values of property ${firstKey} ` +
'and make them available for charting.',
' */',
'SELECT *',
`FROM "${this.addedTable}"`,
`JOIN json_each(doc, '$.${firstKey}')`
].join('\n')
} catch (err) {
console.error(err)
return [
'/*',
` * Your NDJSON file has been imported into ${this.addedTable} table.`,
' */',
'SELECT *',
`FROM "${this.addedTable}"`
].join('\n')
}
} }
} }
} }
@@ -347,10 +460,14 @@ export default {
padding-bottom: 0; padding-bottom: 0;
} }
#csv-json-table-name {
margin-bottom: 24px;
}
.chars { .chars {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
margin: 24px 0 20px; margin: 0 0 20px;
} }
.char-input { .char-input {
margin-right: 44px; margin-right: 44px;
@@ -359,7 +476,7 @@ export default {
margin-top: 18px; margin-top: 18px;
} }
.import-csv-errors { .import-errors {
height: 136px; height: 136px;
margin-top: 8px; margin-top: 8px;
} }

View File

@@ -10,7 +10,8 @@
@click="browse" @click="browse"
> >
<div class="text"> <div class="text">
Drop the database or CSV file here or click to choose a file from your computer. Drop the database, CSV, JSON or NDJSON file here
or click to choose a file from your computer.
</div> </div>
</div> </div>
</div> </div>
@@ -41,13 +42,13 @@
</div> </div>
<div id="error" class="error"></div> <div id="error" class="error"></div>
<!--Parse csv dialog --> <!--Parse csv or json dialog -->
<csv-import <csv-json-import
ref="addCsv" ref="addCsvJson"
:file="file" :file="file"
:db="newDb" :db="newDb"
dialog-name="importFromCsv" dialog-name="importFromCsvJson"
@cancel="cancelCsvImport" @cancel="cancelImport"
@finish="finish" @finish="finish"
/> />
</div> </div>
@@ -57,7 +58,7 @@
import fIo from '@/lib/utils/fileIo' import fIo from '@/lib/utils/fileIo'
import ChangeDbIcon from '@/components/svg/changeDb' import ChangeDbIcon from '@/components/svg/changeDb'
import database from '@/lib/database' import database from '@/lib/database'
import CsvImport from '@/components/CsvImport' import CsvJsonImport from '@/components/CsvJsonImport'
import events from '@/lib/utils/events' import events from '@/lib/utils/events'
export default { export default {
@@ -79,7 +80,7 @@ export default {
}, },
components: { components: {
ChangeDbIcon, ChangeDbIcon,
CsvImport CsvJsonImport
}, },
data () { data () {
return { return {
@@ -102,7 +103,7 @@ export default {
} }
}, },
methods: { methods: {
cancelCsvImport () { cancelImport () {
if (this.newDb) { if (this.newDb) {
this.newDb.shutDown() this.newDb.shutDown()
this.newDb = null this.newDb = null
@@ -128,21 +129,22 @@ export default {
if (fIo.isDatabase(file)) { if (fIo.isDatabase(file)) {
this.loadDb(file) this.loadDb(file)
} else { } else {
const isJson = fIo.isJSON(file) || fIo.isNDJSON(file)
events.send('database.import', file.size, { events.send('database.import', file.size, {
from: 'csv', from: isJson ? 'json' : 'csv',
new_db: true new_db: true
}) })
this.file = file this.file = file
await this.$nextTick() await this.$nextTick()
const csvImport = this.$refs.addCsv const csvJsonImportModal = this.$refs.addCsvJson
csvImport.reset() csvJsonImportModal.reset()
return Promise.all([csvImport.previewCsv(), this.animationPromise]) return Promise.all([csvJsonImportModal.preview(), this.animationPromise])
.then(csvImport.open) .then(csvJsonImportModal.open)
} }
}, },
browse () { browse () {
fIo.getFileFromUser('.db,.sqlite,.sqlite3,.csv') fIo.getFileFromUser('.db,.sqlite,.sqlite3,.csv,.json,.ndjson')
.then(this.checkFile) .then(this.checkFile)
}, },

View File

@@ -75,7 +75,7 @@ export default {
components: { Pager }, components: { Pager },
props: { props: {
dataSet: Object, dataSet: Object,
time: String, time: [String, Number],
pageSize: { pageSize: {
type: Number, type: Number,
default: 20 default: 20

View File

@@ -34,7 +34,7 @@
</defs> </defs>
</svg> </svg>
<span class="icon-tooltip" :style="tooltipStyle" ref="tooltip"> <span class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
Add new table from CSV Add new table from CSV, JSON or NDJSON
</span> </span>
</span> </span>
</template> </template>

View File

@@ -22,7 +22,7 @@
/> />
</svg> </svg>
<span class="icon-tooltip" :style="tooltipStyle" ref="tooltip"> <span class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
Load another database or CSV Load another database, CSV, JSON or NDJSON
</span> </span>
</div> </div>
</template> </template>

View File

@@ -7,9 +7,9 @@ const hintsByCode = {
} }
export default { export default {
getResult (source) { getResult (source, columns) {
const result = { const result = {
columns: [] columns: columns || []
} }
const values = {} const values = {}
if (source.meta.fields) { if (source.meta.fields) {
@@ -24,8 +24,18 @@ export default {
return value return value
}) })
}) })
} else if (columns) {
columns.forEach((col, i) => {
values[col] = source.data.map(row => {
let value = row[i]
if (value instanceof Date) {
value = value.toISOString()
}
return value
})
})
} else { } else {
for (let i = 0; i <= source.data[0].length - 1; i++) { for (let i = 0; source.data[0] && i <= source.data[0].length - 1; i++) {
const colName = `col${i + 1}` const colName = `col${i + 1}`
result.columns.push(colName) result.columns.push(colName)
values[colName] = source.data.map(row => { values[colName] = source.data.map(row => {
@@ -76,7 +86,7 @@ export default {
let res let res
try { try {
res = { res = {
data: this.getResult(results), data: this.getResult(results, config.columns),
delimiter: results.meta.delimiter, delimiter: results.meta.delimiter,
hasErrors: false, hasErrors: false,
rowCount: results.data.length rowCount: results.data.length

View File

@@ -1,4 +1,10 @@
export default { export default {
isJSON (file) {
return file && file.type === 'application/json'
},
isNDJSON (file) {
return file && file.name.endsWith('.ndjson')
},
isDatabase (file) { isDatabase (file) {
const dbTypes = ['application/vnd.sqlite3', 'application/x-sqlite3'] const dbTypes = ['application/vnd.sqlite3', 'application/x-sqlite3']
return file.type return file.type
@@ -51,19 +57,20 @@ export default {
}, },
importFile () { importFile () {
const reader = new FileReader()
return this.getFileFromUser('.json') return this.getFileFromUser('.json')
.then(file => { .then(file => {
return new Promise((resolve, reject) => { return this.getFileContent(file)
reader.onload = e => {
resolve(e.target.result)
}
reader.readAsText(file)
})
}) })
}, },
getFileContent (file) {
const reader = new FileReader()
return new Promise(resolve => {
reader.onload = e => resolve(e.target.result)
reader.readAsText(file)
})
},
readFile (path) { readFile (path) {
return fetch(path) return fetch(path)
}, },

View File

@@ -10,7 +10,7 @@
</div> </div>
<db-uploader id="db-edit" type="small" /> <db-uploader id="db-edit" type="small" />
<export-icon tooltip="Export database" @click="exportToFile"/> <export-icon tooltip="Export database" @click="exportToFile"/>
<add-table-icon @click="addCsv"/> <add-table-icon @click="addCsvJson"/>
</div> </div>
<div v-show="schemaVisible" class="schema"> <div v-show="schemaVisible" class="schema">
<table-description <table-description
@@ -21,12 +21,12 @@
/> />
</div> </div>
<!--Parse csv dialog --> <!--Parse csv or json dialog -->
<csv-import <csv-json-import
ref="addCsv" ref="addCsvJson"
:file="file" :file="file"
:db="$store.state.db" :db="$store.state.db"
dialog-name="addCsv" dialog-name="addCsvJson"
/> />
</div> </div>
</template> </template>
@@ -40,7 +40,7 @@ import TreeChevron from '@/components/svg/treeChevron'
import DbUploader from '@/components/DbUploader' import DbUploader from '@/components/DbUploader'
import ExportIcon from '@/components/svg/export' import ExportIcon from '@/components/svg/export'
import AddTableIcon from '@/components/svg/addTable' import AddTableIcon from '@/components/svg/addTable'
import CsvImport from '@/components/CsvImport' import CsvJsonImport from '@/components/CsvJsonImport'
export default { export default {
name: 'Schema', name: 'Schema',
@@ -51,7 +51,7 @@ export default {
DbUploader, DbUploader,
ExportIcon, ExportIcon,
AddTableIcon, AddTableIcon,
CsvImport CsvJsonImport
}, },
data () { data () {
return { return {
@@ -80,16 +80,17 @@ export default {
exportToFile () { exportToFile () {
this.$store.state.db.export(`${this.dbName}.sqlite`) this.$store.state.db.export(`${this.dbName}.sqlite`)
}, },
async addCsv () { async addCsvJson () {
this.file = await fIo.getFileFromUser('.csv') this.file = await fIo.getFileFromUser('.csv,.json,.ndjson')
await this.$nextTick() await this.$nextTick()
const csvImport = this.$refs.addCsv const csvJsonImportModal = this.$refs.addCsvJson
csvImport.reset() csvJsonImportModal.reset()
await csvImport.previewCsv() await csvJsonImportModal.preview()
csvImport.open() csvJsonImportModal.open()
const isJson = fIo.isJSON(this.file) || fIo.isNDJSON(this.file)
events.send('database.import', this.file.size, { events.send('database.import', this.file.size, {
from: 'csv', from: isJson ? 'json' : 'csv',
new_db: false new_db: false
}) })
} }

View File

@@ -25,7 +25,7 @@
<script> <script>
import plotly from 'plotly.js' import plotly from 'plotly.js'
import 'react-chart-editor/lib/react-chart-editor.min.css' import 'react-chart-editor/lib/react-chart-editor.css'
import PlotlyEditor from 'react-chart-editor' import PlotlyEditor from 'react-chart-editor'
import chartHelper from '@/lib/chartHelper' import chartHelper from '@/lib/chartHelper'

View File

@@ -216,6 +216,6 @@ table.sqliteviz-table {
} }
.column-cell { .column-cell {
max-width: 0; max-width: 150px;
} }
</style> </style>

View File

@@ -25,6 +25,7 @@
v-if="currentFormat === 'json' && formattedJson" v-if="currentFormat === 'json' && formattedJson"
:value="formattedJson" :value="formattedJson"
:options="cmOptions" :options="cmOptions"
class="json-value"
/> />
<pre <pre
v-if="currentFormat === 'text'" v-if="currentFormat === 'text'"
@@ -62,8 +63,8 @@ export default {
data () { data () {
return { return {
formats: [ formats: [
{ text: 'JSON', value: 'json' }, { text: 'Text', value: 'text' },
{ text: 'Text', value: 'text' } { text: 'JSON', value: 'json' }
], ],
currentFormat: 'text', currentFormat: 'text',
cmOptions: { cmOptions: {
@@ -107,6 +108,7 @@ export default {
} }
}, },
cellValue () { cellValue () {
this.messages = []
if (this.currentFormat === 'json') { if (this.currentFormat === 'json') {
this.formatJson(this.cellValue) this.formatJson(this.cellValue)
} }
@@ -154,17 +156,22 @@ export default {
overflow: auto; overflow: auto;
} }
.text-value { .text-value {
padding: 8px 8px; padding: 0 8px;
margin: 0;
color: var(--color-text-base); color: var(--color-text-base);
} }
.json-value {
margin-top: -4px;
}
.text-value.meta-value { .text-value.meta-value {
font-style: italic; font-style: italic;
color: var(--color-text-light-2); color: var(--color-text-light-2);
} }
.messages { .messages {
margin: 8px; margin: 0 8px;
} }
.value-viewer-toolbar button { .value-viewer-toolbar button {

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<main-menu /> <main-menu />
<keep-alive include="Workspace"> <keep-alive include="Workspace,Inquiries">
<router-view id="main-view" /> <router-view id="main-view" />
</keep-alive> </keep-alive>
</div> </div>

View File

@@ -2,10 +2,10 @@ import { expect } from 'chai'
import sinon from 'sinon' import sinon from 'sinon'
import Vuex from 'vuex' import Vuex from 'vuex'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import CsvImport from '@/components/CsvImport' import CsvJsonImport from '@/components/CsvJsonImport'
import csv from '@/lib/csv' import csv from '@/lib/csv'
describe('CsvImport.vue', () => { describe('CsvJsonImport.vue', () => {
let state = {} let state = {}
let actions = {} let actions = {}
let mutations = {} let mutations = {}
@@ -13,7 +13,7 @@ describe('CsvImport.vue', () => {
let clock let clock
let wrapper let wrapper
const newTabId = 1 const newTabId = 1
const file = { name: 'my data.csv' } const file = new File([], 'my data.csv')
beforeEach(() => { beforeEach(() => {
clock = sinon.useFakeTimers() clock = sinon.useFakeTimers()
@@ -40,11 +40,11 @@ describe('CsvImport.vue', () => {
} }
// mount the component // mount the component
wrapper = mount(CsvImport, { wrapper = mount(CsvJsonImport, {
store, store,
propsData: { propsData: {
file, file,
dialogName: 'addCsv', dialogName: 'addCsvJson',
db db
} }
}) })
@@ -74,11 +74,12 @@ describe('CsvImport.vue', () => {
}] }]
}) })
wrapper.vm.previewCsv() wrapper.vm.preview()
await wrapper.vm.open() await wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(true) expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(true)
expect(wrapper.find('#csv-table-name input').element.value).to.equal('my_data') expect(wrapper.find('.dialog-header').text()).to.equal('CSV import')
expect(wrapper.find('#csv-json-table-name input').element.value).to.equal('my_data')
expect(wrapper.findComponent({ name: 'delimiter-selector' }).vm.value).to.equal('|') expect(wrapper.findComponent({ name: 'delimiter-selector' }).vm.value).to.equal('|')
expect(wrapper.find('#quote-char input').element.value).to.equal('"') expect(wrapper.find('#quote-char input').element.value).to.equal('"')
expect(wrapper.find('#escape-char input').element.value).to.equal('"') expect(wrapper.find('#escape-char input').element.value).to.equal('"')
@@ -93,8 +94,36 @@ describe('CsvImport.vue', () => {
.to.include('Information about row 0. Comma was used as a standart delimiter.') .to.include('Information about row 0. Comma was used as a standart delimiter.')
expect(wrapper.findComponent({ name: 'logs' }).text()) expect(wrapper.findComponent({ name: 'logs' }).text())
.to.include('Preview parsing is completed in') .to.include('Preview parsing is completed in')
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false) expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true) expect(wrapper.find('#import-start').isVisible()).to.equal(true)
expect(wrapper.find('#import-start').attributes().disabled).to.equal(undefined)
})
it('disables import if no rows found', async () => {
sinon.stub(csv, 'parse').resolves({
delimiter: '|',
data: {
columns: ['col2', 'col1'],
values: {
col1: [],
col2: []
}
},
rowCount: 0,
messages: []
})
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(0)
expect(wrapper.findComponent({ name: 'logs' }).text())
.to.include('No rows to import.')
expect(wrapper.find('.no-data').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
expect(wrapper.find('#import-start').attributes().disabled).to.equal('disabled')
}) })
it('reparses when parameters changes', async () => { it('reparses when parameters changes', async () => {
@@ -111,7 +140,7 @@ describe('CsvImport.vue', () => {
rowCount: 1 rowCount: 1
}) })
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await csv.parse.returnValues[0] await csv.parse.returnValues[0]
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -231,20 +260,32 @@ describe('CsvImport.vue', () => {
col2: ['foo'] col2: ['foo']
} }
}, },
rowCount: 1 rowCount: 1,
messages: []
}) })
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
let resolveParsing let resolveParsing
parse.onCall(1).returns(new Promise(resolve => { parse.onCall(1).returns(new Promise(resolve => {
resolveParsing = resolve resolveParsing = () => resolve({
delimiter: '|',
data: {
columns: ['col1', 'col2'],
values: {
col1: [1],
col2: ['foo']
}
},
rowCount: 1,
messages: []
})
})) }))
await wrapper.find('#csv-table-name input').setValue('foo') await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
// "Parsing CSV..." in the logs // "Parsing CSV..." in the logs
@@ -262,11 +303,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(true) expect(wrapper.find('#quote-char input').element.disabled).to.equal(true)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(true) expect(wrapper.find('#escape-char input').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true) expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(true) expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(true) expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true) expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false) expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true) expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveParsing() await resolveParsing()
await parse.returnValues[1] await parse.returnValues[1]
@@ -306,7 +347,7 @@ describe('CsvImport.vue', () => {
messages: [] messages: []
}) })
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -315,8 +356,8 @@ describe('CsvImport.vue', () => {
resolveImport = resolve resolveImport = resolve
})) }))
await wrapper.find('#csv-table-name input').setValue('foo') await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1] await csv.parse.returnValues[1]
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -329,11 +370,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(true) expect(wrapper.find('#quote-char input').element.disabled).to.equal(true)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(true) expect(wrapper.find('#escape-char input').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true) expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(true) expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(true) expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true) expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false) expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true) expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveImport() await resolveImport()
}) })
@@ -377,12 +418,12 @@ describe('CsvImport.vue', () => {
resolveImport = resolve resolveImport = resolve
})) }))
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo') await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1] await csv.parse.returnValues[1]
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -397,11 +438,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(true) expect(wrapper.find('#quote-char input').element.disabled).to.equal(true)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(true) expect(wrapper.find('#escape-char input').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true) expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(true) expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(true) expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true) expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false) expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true) expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveImport() await resolveImport()
}) })
@@ -440,12 +481,12 @@ describe('CsvImport.vue', () => {
}] }]
}) })
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo') await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1] await csv.parse.returnValues[1]
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -460,11 +501,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(false) expect(wrapper.find('#quote-char input').element.disabled).to.equal(false)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(false) expect(wrapper.find('#escape-char input').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false) expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(false) expect(wrapper.find('#import-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(false) expect(wrapper.find('#import-finish').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false) expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false) expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true) expect(wrapper.find('#import-start').isVisible()).to.equal(true)
}) })
it('has proper state before import is completed', async () => { it('has proper state before import is completed', async () => {
@@ -501,12 +542,12 @@ describe('CsvImport.vue', () => {
wrapper.vm.db.addTableFromCsv = sinon.stub() wrapper.vm.db.addTableFromCsv = sinon.stub()
.resolves(new Promise(resolve => { resolveImport = resolve })) .resolves(new Promise(resolve => { resolveImport = resolve }))
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo') await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1] await csv.parse.returnValues[1]
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -525,11 +566,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(true) expect(wrapper.find('#quote-char input').element.disabled).to.equal(true)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(true) expect(wrapper.find('#escape-char input').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true) expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(true) expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(true) expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true) expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false) expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true) expect(wrapper.find('#import-start').isVisible()).to.equal(true)
expect(wrapper.vm.db.addTableFromCsv.getCall(0).args[0]).to.equal('foo') // table name expect(wrapper.vm.db.addTableFromCsv.getCall(0).args[0]).to.equal('foo') // table name
// After resolving - loading indicator is not shown // After resolving - loading indicator is not shown
@@ -570,12 +611,12 @@ describe('CsvImport.vue', () => {
messages: [] messages: []
}) })
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo') await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1] await csv.parse.returnValues[1]
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -589,10 +630,10 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(false) expect(wrapper.find('#quote-char input').element.disabled).to.equal(false)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(false) expect(wrapper.find('#escape-char input').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false) expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(false) expect(wrapper.find('#import-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(false) expect(wrapper.find('#import-finish').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false) expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(true) expect(wrapper.find('#import-finish').isVisible()).to.equal(true)
}) })
it('import fails', async () => { it('import fails', async () => {
@@ -627,12 +668,12 @@ describe('CsvImport.vue', () => {
wrapper.vm.db.addTableFromCsv = sinon.stub().rejects(new Error('fail')) wrapper.vm.db.addTableFromCsv = sinon.stub().rejects(new Error('fail'))
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo') await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1] await csv.parse.returnValues[1]
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -647,10 +688,10 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(false) expect(wrapper.find('#quote-char input').element.disabled).to.equal(false)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(false) expect(wrapper.find('#escape-char input').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false) expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(false) expect(wrapper.find('#import-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(false) expect(wrapper.find('#import-finish').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false) expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false) expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
}) })
it('import finish', async () => { it('import finish', async () => {
@@ -668,19 +709,19 @@ describe('CsvImport.vue', () => {
messages: [] messages: []
}) })
wrapper.vm.previewCsv() wrapper.vm.preview()
wrapper.vm.open() wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-finish').trigger('click') await wrapper.find('#import-finish').trigger('click')
expect(actions.addTab.calledOnce).to.equal(true) expect(actions.addTab.calledOnce).to.equal(true)
await actions.addTab.returnValues[0] await actions.addTab.returnValues[0]
expect(mutations.setCurrentTabId.calledOnceWith(state, newTabId)).to.equal(true) expect(mutations.setCurrentTabId.calledOnceWith(state, newTabId)).to.equal(true)
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(false) expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(false)
expect(wrapper.emitted('finish')).to.have.lengthOf(1) expect(wrapper.emitted('finish')).to.have.lengthOf(1)
}) })
@@ -699,47 +740,525 @@ describe('CsvImport.vue', () => {
messages: [] messages: []
}) })
await wrapper.vm.previewCsv() await wrapper.vm.preview()
await wrapper.vm.open() await wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-cancel').trigger('click') await wrapper.find('#import-cancel').trigger('click')
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(false) expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(false)
expect(wrapper.vm.db.execute.calledOnceWith('DROP TABLE "my_data"')).to.equal(true) expect(wrapper.vm.db.execute.calledOnceWith('DROP TABLE "my_data"')).to.equal(true)
expect(wrapper.vm.db.refreshSchema.calledOnce).to.equal(true) expect(wrapper.vm.db.refreshSchema.calledOnce).to.equal(true)
expect(wrapper.emitted('cancel')).to.have.lengthOf(1) expect(wrapper.emitted('cancel')).to.have.lengthOf(1)
}) })
it('checks table name', async () => { it('checks table name', async () => {
sinon.stub(csv, 'parse').resolves() sinon.stub(csv, 'parse').resolves({
await wrapper.vm.previewCsv() data: {},
hasErrors: false,
messages: []
})
await wrapper.vm.preview()
await wrapper.vm.open() await wrapper.vm.open()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo') await wrapper.find('#csv-json-table-name input').setValue('foo')
await clock.tick(400) await clock.tick(400)
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('#csv-table-name .text-field-error').text()).to.equal('') expect(wrapper.find('#csv-json-table-name .text-field-error').text()).to.equal('')
wrapper.vm.db.validateTableName = sinon.stub().rejects(new Error('this is a bad table name')) wrapper.vm.db.validateTableName = sinon.stub().rejects(new Error('this is a bad table name'))
await wrapper.find('#csv-table-name input').setValue('bar') await wrapper.find('#csv-json-table-name input').setValue('bar')
await clock.tick(400) await clock.tick(400)
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('#csv-table-name .text-field-error').text()) expect(wrapper.find('#csv-json-table-name .text-field-error').text())
.to.equal('this is a bad table name. Try another table name.') .to.equal('this is a bad table name. Try another table name.')
await wrapper.find('#csv-table-name input').setValue('') await wrapper.find('#csv-json-table-name input').setValue('')
await clock.tick(400) await clock.tick(400)
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('#csv-table-name .text-field-error').text()).to.equal('') expect(wrapper.find('#csv-json-table-name .text-field-error').text()).to.equal('')
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
expect(wrapper.find('#csv-table-name .text-field-error').text()) expect(wrapper.find('#csv-json-table-name .text-field-error').text())
.to.equal("Table name can't be empty") .to.equal("Table name can't be empty")
expect(wrapper.vm.db.addTableFromCsv.called).to.equal(false) expect(wrapper.vm.db.addTableFromCsv.called).to.equal(false)
}) })
}) })
describe('CsvJsonImport.vue - json', () => {
let state = {}
let actions = {}
let mutations = {}
let store = {}
let clock
let wrapper
const newTabId = 1
const file = new File(
[new Blob(
[JSON.stringify({ foo: [1, 2, 3] }, null, 2)],
{ type: 'application/json' }
)],
'my data.json',
{ type: 'application/json' })
beforeEach(() => {
clock = sinon.useFakeTimers()
// mock store state and mutations
state = {}
mutations = {
setDb: sinon.stub(),
setCurrentTabId: sinon.stub()
}
actions = {
addTab: sinon.stub().resolves(newTabId)
}
store = new Vuex.Store({ state, mutations, actions })
const db = {
sanitizeTableName: sinon.stub().returns('my_data'),
addTableFromCsv: sinon.stub().resolves(),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub(),
validateTableName: sinon.stub().resolves(),
execute: sinon.stub().resolves(),
refreshSchema: sinon.stub().resolves()
}
// mount the component
wrapper = mount(CsvJsonImport, {
store,
propsData: {
file,
dialogName: 'addCsvJson',
db
}
})
})
afterEach(() => {
sinon.restore()
})
it('previews', async () => {
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(true)
expect(wrapper.find('.dialog-header').text()).to.equal('JSON import')
expect(wrapper.find('#csv-json-table-name input').element.value).to.equal('my_data')
expect(wrapper.findComponent({ name: 'delimiter-selector' }).exists()).to.equal(false)
expect(wrapper.find('#quote-char input').exists()).to.equal(false)
expect(wrapper.find('#escape-char input').exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).exists()).to.equal(false)
const rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(1)
expect(rows.at(0).findAll('td').at(0).text()).to.equal([
'{',
' "foo": [',
' 1,',
' 2,',
' 3',
' ]',
'}'
].join('\n')
)
expect(wrapper.findComponent({ name: 'logs' }).text())
.to.include('Preview parsing is completed in')
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
})
it('has proper state before parsing is complete', async () => {
const getJsonParseResult = sinon.stub(wrapper.vm, 'getJsonParseResult')
getJsonParseResult.onCall(0).returns({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
let resolveParsing
getJsonParseResult.onCall(1).returns(new Promise(resolve => {
resolveParsing = () => resolve({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
}))
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick()
// "Parsing JSON..." in the logs
expect(wrapper.findComponent({ name: 'logs' }).findAll('.msg').at(1).text())
.to.equal('Parsing JSON...')
// After 1 second - loading indicator is shown
await clock.tick(1000)
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(true)
// All the dialog controls are disabled
expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveParsing()
await getJsonParseResult.returnValues[1]
// Loading indicator is not shown when parsing is compete
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
})
it('has proper state before import is completed', async () => {
const getJsonParseResult = sinon.spy(wrapper.vm, 'getJsonParseResult')
let resolveImport = sinon.stub()
wrapper.vm.db.addTableFromCsv = sinon.stub()
.resolves(new Promise(resolve => { resolveImport = resolve }))
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await getJsonParseResult.returnValues[1]
await wrapper.vm.$nextTick()
// Parsing success in the logs
expect(wrapper.findComponent({ name: 'logs' }).findAll('.msg').at(2).text())
.to.equal('Importing JSON into a SQLite database...')
// After 1 second - loading indicator is shown
await clock.tick(1000)
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(true)
// All the dialog controls are disabled
expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
expect(wrapper.vm.db.addTableFromCsv.getCall(0).args[0]).to.equal('foo') // table name
// After resolving - loading indicator is not shown
await resolveImport()
await wrapper.vm.db.addTableFromCsv.returnValues[0]
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
})
it('import success', async () => {
const getJsonParseResult = sinon.spy(wrapper.vm, 'getJsonParseResult')
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await getJsonParseResult.returnValues[1]
await wrapper.vm.$nextTick()
// Import success in the logs
const logs = wrapper.findComponent({ name: 'logs' }).findAll('.msg')
expect(logs).to.have.lengthOf(3)
expect(logs.at(2).text()).to.contain('Importing JSON into a SQLite database is completed in')
// All the dialog controls are enabled
expect(wrapper.find('#import-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#import-finish').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false)
expect(wrapper.find('#import-finish').isVisible()).to.equal(true)
})
})
describe('CsvJsonImport.vue - ndjson', () => {
let state = {}
let actions = {}
let mutations = {}
let store = {}
let clock
let wrapper
const newTabId = 1
const file = new File([], 'my data.ndjson')
beforeEach(() => {
clock = sinon.useFakeTimers()
// mock store state and mutations
state = {}
mutations = {
setDb: sinon.stub(),
setCurrentTabId: sinon.stub()
}
actions = {
addTab: sinon.stub().resolves(newTabId)
}
store = new Vuex.Store({ state, mutations, actions })
const db = {
sanitizeTableName: sinon.stub().returns('my_data'),
addTableFromCsv: sinon.stub().resolves(),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub(),
validateTableName: sinon.stub().resolves(),
execute: sinon.stub().resolves(),
refreshSchema: sinon.stub().resolves()
}
// mount the component
wrapper = mount(CsvJsonImport, {
store,
propsData: {
file,
dialogName: 'addCsvJson',
db
}
})
})
afterEach(() => {
sinon.restore()
})
it('previews', async () => {
sinon.stub(csv, 'parse').resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
messages: []
})
wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(true)
expect(wrapper.find('.dialog-header').text()).to.equal('JSON import')
expect(wrapper.find('#csv-json-table-name input').element.value).to.equal('my_data')
expect(wrapper.findComponent({ name: 'delimiter-selector' }).exists()).to.equal(false)
expect(wrapper.find('#quote-char input').exists()).to.equal(false)
expect(wrapper.find('#escape-char input').exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).exists()).to.equal(false)
const rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(1)
expect(rows.at(0).findAll('td').at(0).text()).to.equal('{ "foo": [ 1, 2, 3 ] }')
expect(wrapper.findComponent({ name: 'logs' }).text())
.to.include('Preview parsing is completed in')
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
})
it('has proper state before parsing is complete', async () => {
const parse = sinon.stub(csv, 'parse')
parse.onCall(0).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1
})
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
let resolveParsing
parse.onCall(1).returns(new Promise(resolve => {
resolveParsing = () => resolve({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
messages: []
})
}))
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick()
// "Parsing JSON..." in the logs
expect(wrapper.findComponent({ name: 'logs' }).findAll('.msg').at(1).text())
.to.equal('Parsing JSON...')
// After 1 second - loading indicator is shown
await clock.tick(1000)
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(true)
// All the dialog controls are disabled
expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveParsing()
await parse.returnValues[1]
// Loading indicator is not shown when parsing is compete
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
})
it('has proper state before import is completed', async () => {
const parse = sinon.stub(csv, 'parse')
parse.onCall(0).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
parse.onCall(1).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
let resolveImport = sinon.stub()
wrapper.vm.db.addTableFromCsv = sinon.stub()
.resolves(new Promise(resolve => { resolveImport = resolve }))
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
// Parsing success in the logs
expect(wrapper.findComponent({ name: 'logs' }).findAll('.msg').at(2).text())
.to.equal('Importing JSON into a SQLite database...')
// After 1 second - loading indicator is shown
await clock.tick(1000)
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(true)
// All the dialog controls are disabled
expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
expect(wrapper.vm.db.addTableFromCsv.getCall(0).args[0]).to.equal('foo') // table name
// After resolving - loading indicator is not shown
await resolveImport()
await wrapper.vm.db.addTableFromCsv.returnValues[0]
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
})
it('import success', async () => {
const parse = sinon.stub(csv, 'parse')
parse.onCall(0).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
// we need to separate calles because messages will mutate
parse.onCall(1).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 2,
hasErrors: false,
messages: []
})
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
// Import success in the logs
const logs = wrapper.findComponent({ name: 'logs' }).findAll('.msg')
expect(logs).to.have.lengthOf(3)
expect(logs.at(2).text()).to.contain('Importing JSON into a SQLite database is completed in')
// All the dialog controls are enabled
expect(wrapper.find('#import-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#import-finish').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false)
expect(wrapper.find('#import-finish').isVisible()).to.equal(true)
})
})

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai' import { expect } from 'chai'
import { mount, shallowMount } from '@vue/test-utils' import { mount, shallowMount } from '@vue/test-utils'
import DelimiterSelector from '@/components/CsvImport/DelimiterSelector' import DelimiterSelector from '@/components/CsvJsonImport/DelimiterSelector'
describe('DelimiterSelector', async () => { describe('DelimiterSelector', async () => {
it('shows the name of value', async () => { it('shows the name of value', async () => {

View File

@@ -31,7 +31,7 @@ describe('DbUploader.vue', () => {
it('loads db on click and redirects to /workspace', async () => { it('loads db on click and redirects to /workspace', async () => {
// mock getting a file from user // mock getting a file from user
const file = { name: 'test.db' } const file = new File([], 'test.db')
sinon.stub(fu, 'getFileFromUser').resolves(file) sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock db loading // mock db loading
@@ -85,7 +85,7 @@ describe('DbUploader.vue', () => {
}) })
// mock a file dropped by a user // mock a file dropped by a user
const file = { name: 'test.db' } const file = new File([], 'test.db')
const dropData = { dataTransfer: new DataTransfer() } const dropData = { dataTransfer: new DataTransfer() }
Object.defineProperty(dropData.dataTransfer, 'files', { Object.defineProperty(dropData.dataTransfer, 'files', {
value: [file], value: [file],
@@ -103,7 +103,7 @@ describe('DbUploader.vue', () => {
it("doesn't redirect if already on /workspace", async () => { it("doesn't redirect if already on /workspace", async () => {
// mock getting a file from user // mock getting a file from user
const file = { name: 'test.db' } const file = new File([], 'test.db')
sinon.stub(fu, 'getFileFromUser').resolves(file) sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock db loading // mock db loading
@@ -136,7 +136,7 @@ describe('DbUploader.vue', () => {
it('shows parse dialog if gets csv file', async () => { it('shows parse dialog if gets csv file', async () => {
// mock getting a file from user // mock getting a file from user
const file = { name: 'test.csv' } const file = new File([], 'test.csv')
sinon.stub(fu, 'getFileFromUser').resolves(file) sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock router // mock router
@@ -153,24 +153,92 @@ describe('DbUploader.vue', () => {
} }
}) })
const CsvImport = wrapper.vm.$refs.addCsv const CsvImport = wrapper.vm.$refs.addCsvJson
sinon.stub(CsvImport, 'reset') sinon.stub(CsvImport, 'reset')
sinon.stub(CsvImport, 'previewCsv').resolves() sinon.stub(CsvImport, 'preview').resolves()
sinon.stub(CsvImport, 'open') sinon.stub(CsvImport, 'open')
await wrapper.find('.drop-area').trigger('click') await wrapper.find('.drop-area').trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(CsvImport.reset.calledOnce).to.equal(true) expect(CsvImport.reset.calledOnce).to.equal(true)
await wrapper.vm.animationPromise await wrapper.vm.animationPromise
expect(CsvImport.previewCsv.calledOnce).to.equal(true) expect(CsvImport.preview.calledOnce).to.equal(true)
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(CsvImport.open.calledOnce).to.equal(true) expect(CsvImport.open.calledOnce).to.equal(true)
wrapper.destroy() wrapper.destroy()
}) })
it('deletes temporary db if CSV import is canceled', async () => { it('shows parse dialog if gets json file', async () => {
// mock getting a file from user // mock getting a file from user
const file = { name: 'test.csv' } const file = new File([], 'test.json', { type: 'application/json' })
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock router
const $router = { push: sinon.stub() }
const $route = { path: '/workspace' }
// mount the component
const wrapper = mount(DbUploader, {
attachTo: place,
store,
mocks: { $router, $route },
propsData: {
type: 'illustrated'
}
})
const JsonImport = wrapper.vm.$refs.addCsvJson
sinon.stub(JsonImport, 'reset')
sinon.stub(JsonImport, 'preview').resolves()
sinon.stub(JsonImport, 'open')
await wrapper.find('.drop-area').trigger('click')
await wrapper.vm.$nextTick()
expect(JsonImport.reset.calledOnce).to.equal(true)
await wrapper.vm.animationPromise
expect(JsonImport.preview.calledOnce).to.equal(true)
await wrapper.vm.$nextTick()
expect(JsonImport.open.calledOnce).to.equal(true)
wrapper.destroy()
})
it('shows parse dialog if gets ndjson file', async () => {
// mock getting a file from user
const file = new File([], 'test.ndjson')
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock router
const $router = { push: sinon.stub() }
const $route = { path: '/workspace' }
// mount the component
const wrapper = mount(DbUploader, {
attachTo: place,
store,
mocks: { $router, $route },
propsData: {
type: 'illustrated'
}
})
const JsonImport = wrapper.vm.$refs.addCsvJson
sinon.stub(JsonImport, 'reset')
sinon.stub(JsonImport, 'preview').resolves()
sinon.stub(JsonImport, 'open')
await wrapper.find('.drop-area').trigger('click')
await wrapper.vm.$nextTick()
expect(JsonImport.reset.calledOnce).to.equal(true)
await wrapper.vm.animationPromise
expect(JsonImport.preview.calledOnce).to.equal(true)
await wrapper.vm.$nextTick()
expect(JsonImport.open.calledOnce).to.equal(true)
wrapper.destroy()
})
it('deletes temporary db if import is canceled', async () => {
// mock getting a file from user
const file = new File([], 'test.csv')
sinon.stub(fu, 'getFileFromUser').resolves(file) sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock router // mock router
@@ -186,9 +254,9 @@ describe('DbUploader.vue', () => {
} }
}) })
const CsvImport = wrapper.vm.$refs.addCsv const CsvImport = wrapper.vm.$refs.addCsvJson
sinon.stub(CsvImport, 'reset') sinon.stub(CsvImport, 'reset')
sinon.stub(CsvImport, 'previewCsv').resolves() sinon.stub(CsvImport, 'preview').resolves()
sinon.stub(CsvImport, 'open') sinon.stub(CsvImport, 'open')
await wrapper.find('.drop-area').trigger('click') await wrapper.find('.drop-area').trigger('click')

View File

@@ -28,7 +28,26 @@ describe('csv.js', () => {
}) })
}) })
it('getResult without fields', () => { it('getResult without fields but with columns', () => {
const source = {
data: [
[1, 'foo', new Date('2021-06-30T14:10:24.717Z')],
[2, 'bar', new Date('2021-07-30T14:10:15.717Z')]
],
meta: {}
}
const columns = ['id', 'name', 'date']
expect(csv.getResult(source, columns)).to.eql({
columns: ['id', 'name', 'date'],
values: {
id: [1, 2],
name: ['foo', 'bar'],
date: ['2021-06-30T14:10:24.717Z', '2021-07-30T14:10:15.717Z']
}
})
})
it('getResult without fields and columns', () => {
const source = { const source = {
data: [ data: [
[1, 'foo', new Date('2021-06-30T14:10:24.717Z')], [1, 'foo', new Date('2021-06-30T14:10:24.717Z')],

View File

@@ -455,4 +455,85 @@ describe('SQLite extensions', function () {
xx: [1], xy: [0], xz: [1], yx: [0], yy: [1], yz: [1], zx: [1], zy: [1], zz: [1] xx: [1], xy: [0], xz: [1], yx: [0], yy: [1], yz: [1], zx: [1], zy: [1], zz: [1]
}) })
}) })
it('supports simple Lua functions', async function () {
const actual = await db.execute(`
INSERT INTO
luafunctions(name, src)
VALUES
('lua_inline', 'return {"arg"}, {"rv"}, "simple", function(arg) return arg + 1 end'),
('lua_full', '
local input = {"arg"}
local output = {"rv"}
local function func(x)
return math.sin(math.pi) + x
end
return input, output, "simple", func
');
SELECT lua_inline(1), lua_full(1) - 1 < 0.000001;
`)
expect(actual.values).to.eql({ 'lua_inline(1)': [2], 'lua_full(1) - 1 < 0.000001': [1] })
})
it('supports aggregate Lua functions', async function () {
const actual = await db.execute(`
INSERT INTO
luafunctions(name, src)
VALUES
('lua_sum', '
local inputs = {"item"}
local outputs = {"sum"}
local function func(item)
if aggregate_now(item) then
return item
end
local sum = 0
while true do
if aggregate_now(item) then
break
end
sum = sum + item
item = coroutine.yield()
end
return sum
end
return inputs, outputs, "aggregate", func
');
SELECT SUM(value), lua_sum(value) FROM generate_series(1, 10);
`)
expect(actual.values).to.eql({ 'SUM(value)': [55], 'lua_sum(value)': [55] })
})
it('supports table-valued Lua functions', async function () {
const actual = await db.execute(`
INSERT INTO
luafunctions(name, src)
VALUES
('lua_match', '
local inputs = {"pattern", "s"}
local outputs = {"idx", "elm"}
local function func(pattern, s)
local i = 1
for k in s:gmatch(pattern) do
coroutine.yield(i, k)
i = i + 1
end
end
return inputs, outputs, "table", func
');
SELECT * FROM lua_match('%w+', 'hello world from Lua');
`)
expect(actual.values).to.eql({ idx: [1, 2, 3, 4], elm: ['hello', 'world', 'from', 'Lua'] })
})
}) })

View File

@@ -106,10 +106,65 @@ describe('fileIo.js', () => {
await expect(fIo.readAsArrayBuffer(blob)).to.be.rejectedWith('Problem parsing input file.') await expect(fIo.readAsArrayBuffer(blob)).to.be.rejectedWith('Problem parsing input file.')
}) })
it('isJSON', () => {
let file = { type: 'application/json' }
expect(fIo.isJSON(file)).to.equal(true)
file = { type: 'application/x-sqlite3' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.db' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.sqlite' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.sqlite3' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.csv' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.ndjson' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: 'text', name: 'test.db' }
expect(fIo.isJSON(file)).to.equal(false)
})
it('isNDJSON', () => {
let file = { type: 'application/json', name: 'test.json' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: 'application/x-sqlite3', name: 'test.sqlite3' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.db' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.sqlite' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.sqlite3' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.csv' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.ndjson' }
expect(fIo.isNDJSON(file)).to.equal(true)
file = { type: 'text', name: 'test.db' }
expect(fIo.isNDJSON(file)).to.equal(false)
})
it('isDatabase', () => { it('isDatabase', () => {
let file = { type: 'application/vnd.sqlite3' } let file = { type: 'application/vnd.sqlite3' }
expect(fIo.isDatabase(file)).to.equal(true) expect(fIo.isDatabase(file)).to.equal(true)
file = { type: 'application/json' }
expect(fIo.isDatabase(file)).to.equal(false)
file = { type: 'application/x-sqlite3' } file = { type: 'application/x-sqlite3' }
expect(fIo.isDatabase(file)).to.equal(true) expect(fIo.isDatabase(file)).to.equal(true)
@@ -125,6 +180,9 @@ describe('fileIo.js', () => {
file = { type: '', name: 'test.csv' } file = { type: '', name: 'test.csv' }
expect(fIo.isDatabase(file)).to.equal(false) expect(fIo.isDatabase(file)).to.equal(false)
file = { type: '', name: 'test.ndjson' }
expect(fIo.isDatabase(file)).to.equal(false)
file = { type: 'text', name: 'test.db' } file = { type: 'text', name: 'test.db' }
expect(fIo.isDatabase(file)).to.equal(false) expect(fIo.isDatabase(file)).to.equal(false)
}) })

View File

@@ -29,12 +29,12 @@ describe('time.js', () => {
}) })
it('sleep resolves after n ms', async () => { it('sleep resolves after n ms', async () => {
let before = Date.now() let before = performance.now()
await time.sleep(10) await time.sleep(10)
expect(Date.now() - before).to.be.least(10) expect(performance.now() - before).to.be.least(10)
before = Date.now() before = performance.now()
await time.sleep(30) await time.sleep(30)
expect(Date.now() - before).to.be.least(30) expect(performance.now() - before).to.be.least(30)
}) })
}) })

View File

@@ -125,7 +125,7 @@ describe('Schema.vue', () => {
}) })
it('adds table', async () => { it('adds table', async () => {
const file = { name: 'test.csv' } const file = new File([], 'test.csv')
sinon.stub(fIo, 'getFileFromUser').resolves(file) sinon.stub(fIo, 'getFileFromUser').resolves(file)
sinon.stub(csv, 'parse').resolves({ sinon.stub(csv, 'parse').resolves({
@@ -152,20 +152,20 @@ describe('Schema.vue', () => {
const store = new Vuex.Store({ state, actions, mutations }) const store = new Vuex.Store({ state, actions, mutations })
const wrapper = mount(Schema, { store, localVue }) const wrapper = mount(Schema, { store, localVue })
sinon.spy(wrapper.vm.$refs.addCsv, 'previewCsv') sinon.spy(wrapper.vm.$refs.addCsvJson, 'preview')
sinon.spy(wrapper.vm, 'addCsv') sinon.spy(wrapper.vm, 'addCsvJson')
sinon.spy(wrapper.vm.$refs.addCsv, 'loadFromCsv') sinon.spy(wrapper.vm.$refs.addCsvJson, 'loadToDb')
await wrapper.findComponent({ name: 'add-table-icon' }).find('svg').trigger('click') await wrapper.findComponent({ name: 'add-table-icon' }).find('svg').trigger('click')
await wrapper.vm.$refs.addCsv.previewCsv.returnValues[0] await wrapper.vm.$refs.addCsvJson.preview.returnValues[0]
await wrapper.vm.addCsv.returnValues[0] await wrapper.vm.addCsvJson.returnValues[0]
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(true) expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(true)
await wrapper.find('#csv-import').trigger('click') await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$refs.addCsv.loadFromCsv.returnValues[0] await wrapper.vm.$refs.addCsvJson.loadToDb.returnValues[0]
await wrapper.find('#csv-finish').trigger('click') await wrapper.find('#import-finish').trigger('click')
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(false) expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(false)
await state.db.refreshSchema.returnValues[0] await state.db.refreshSchema.returnValues[0]
expect(wrapper.vm.$store.state.db.schema).to.eql([ expect(wrapper.vm.$store.state.db.schema).to.eql([