Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ target_sources(${PROJECT_NAME}
src/csvparser.h
src/sqlite.h
src/Data.h
src/JsonbUtil.h
src/IconCache.h
src/sql/parser/ParserDriver.h
src/sql/parser/sqlite3_lexer.h
Expand Down Expand Up @@ -206,6 +207,7 @@ target_sources(${PROJECT_NAME}
src/FileExtensionManager.cpp
src/CondFormatManager.cpp
src/Data.cpp
src/JsonbUtil.cpp
src/CipherSettings.cpp
src/Palette.cpp
src/CondFormat.cpp
Expand Down
30 changes: 28 additions & 2 deletions src/EditDialog.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "EditDialog.h"
#include "ui_EditDialog.h"
#include "Settings.h"
#include "JsonbUtil.h"
#include "qhexedit.h"
#include "docktextedit.h"
#include "FileDialog.h"
Expand Down Expand Up @@ -194,6 +195,9 @@ void EditDialog::loadData(const QByteArray& bArrdata)
// Get the current editor mode (eg text, hex, image, json or xml mode)
int editMode = ui->comboMode->currentIndex();

// The JSONB view below makes the text editor read-only independently of the dialog's state
sciEdit->setReadOnly(isReadOnly);

// Data type specific handling
switch (dataType) {
case Null:
Expand Down Expand Up @@ -359,13 +363,31 @@ void EditDialog::loadData(const QByteArray& bArrdata)
case TextEditor:
case JsonEditor:
case XmlEditor:
case SqlEvaluator:
case SqlEvaluator: {
// SQLite's binary JSON format can be shown as read-only JSON text. The data stays in the hex buffer.
QString jsonbText;
if(jsonbToText(bArrdata, jsonbText))
{
if(editMode == JsonEditor && mustIndentAndCompact)
{
auto jsonDoc = ordered_json::parse(jsonbText.toStdString(), nullptr, false);
if(!jsonDoc.is_discarded())
jsonbText = QString::fromStdString(jsonDoc.dump(4));
}
sciEdit->setText(jsonbText);
sciEdit->setTextInMargin(tr("JSONB"));
sciEdit->setReadOnly(true);
sciEdit->setEnabled(true);
break;
}

// Disable text editing, and use a warning message as the contents
sciEdit->setText(QString(tr("Binary data can't be viewed in this mode.") % '\n' %
tr("Try switching to Binary mode.")));
sciEdit->setTextInMargin(Settings::getValue("databrowser", "blob_text").toString());
sciEdit->setEnabled(false);
break;
}

case RtlTextEditor:
// Disable text editing, and use a warning message as the contents
Expand Down Expand Up @@ -1149,7 +1171,11 @@ void EditDialog::updateCellInfoAndMode(const QByteArray& bArrdata)
// Determine the length of the cell data
int dataLength = cellData.length();
// If none of the above data types, consider it general binary data
ui->labelInfo->setText(tr("Type: Binary; Size: %n byte(s)", "", dataLength));
QString jsonbText;
if(jsonbToText(cellData, jsonbText))
ui->labelInfo->setText(tr("Type: Binary (JSONB); Size: %n byte(s)", "", dataLength));
else
ui->labelInfo->setText(tr("Type: Binary; Size: %n byte(s)", "", dataLength));
break;
}
}
Expand Down
57 changes: 57 additions & 0 deletions src/JsonbUtil.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#include "JsonbUtil.h"

#include <sqlite3.h>

bool jsonbToText(const QByteArray& data, QString& text)
{
if(data.isEmpty() || sqlite3_libversion_number() < 3045000)
return false;

// The lower nibble of the first byte is the element type: 0xB is an array, 0xC is an object.
const auto byteAt = [&data](int i) { return static_cast<quint64>(static_cast<unsigned char>(data.at(i))); };
const unsigned char header = static_cast<unsigned char>(data.at(0));
const unsigned char type = header & 0x0F;
if(type != 0x0B && type != 0x0C)
return false;

// The upper nibble is either the payload size or, for values above 11, the number of bytes that hold the size. Requiring that the
// size matches the length of the data rejects most text and binary data which happens to start with a suitable byte.
quint64 payload_size = header >> 4;
int header_size = 1;
if(payload_size > 11)
{
const int size_bytes = 1 << (payload_size - 12); // 1, 2, 4 or 8 bytes in big endian order
if(data.size() < 1 + size_bytes)
return false;
payload_size = 0;
for(int i=0;i<size_bytes;++i)
payload_size = (payload_size << 8) | byteAt(1 + i);
header_size += size_bytes;
}
if(payload_size != static_cast<quint64>(data.size() - header_size))
return false;

// json() validates the whole blob and fails for anything that is not well-formed JSONB
sqlite3* db = nullptr;
if(sqlite3_open(":memory:", &db) != SQLITE_OK)
{
sqlite3_close(db);
return false;
}

bool success = false;
sqlite3_stmt* stmt = nullptr;
if(sqlite3_prepare_v2(db, "SELECT json(?1)", -1, &stmt, nullptr) == SQLITE_OK)
{
if(sqlite3_bind_blob(stmt, 1, data.constData(), data.size(), SQLITE_STATIC) == SQLITE_OK &&
sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) == SQLITE_TEXT)
{
text = QString::fromUtf8(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0)), sqlite3_column_bytes(stmt, 0));
success = true;
}
}
sqlite3_finalize(stmt);
sqlite3_close(db);

return success;
}
11 changes: 11 additions & 0 deletions src/JsonbUtil.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#ifndef JSONBUTIL_H
#define JSONBUTIL_H

#include <QByteArray>
#include <QString>

// Checks whether the data is a SQLite JSONB array or object (JSONB needs SQLite 3.45.0 or newer) and, if so, converts it to JSON text.
// Only arrays and objects are detected because almost any other short blob is valid JSONB too (e.g. a single 0x01 byte is 'true').
bool jsonbToText(const QByteArray& data, QString& text);

#endif
71 changes: 71 additions & 0 deletions src/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,74 @@ set(TESTCACHE_HDR
add_executable(test-cache ${TESTCACHE_HDR} ${TESTCACHE_SRC})
target_link_libraries(test-cache ${QT_MAJOR}::Test)
add_test(NAME test-cache COMMAND $<TARGET_FILE:test-cache>)

# test JSONB detection

set(TESTJSONB_SRC
TestJsonb.cpp
../JsonbUtil.cpp
)

set(TESTJSONB_HDR
TestJsonb.h
../JsonbUtil.h
)

add_executable(test-jsonb ${TESTJSONB_HDR} ${TESTJSONB_SRC})
target_link_libraries(test-jsonb ${QT_MAJOR}::Test ${LIBSQLITE_NAME})
add_test(NAME test-jsonb COMMAND $<TARGET_FILE:test-jsonb>)
if(WIN32)
# Like test-global-filter, this needs to find the SQLite DLL built by the CI jobs
set_tests_properties(test-jsonb PROPERTIES ENVIRONMENT "PATH=${TESTGLOBALFILTER_PATH}")
endif()

# test edit dialog

set(TESTEDITDIALOG_SRC
TestEditDialog.cpp
../EditDialog.cpp
../EditDialog.ui
../docktextedit.cpp
../sqltextedit.cpp
../ExtendedScintilla.cpp
../FindReplaceDialog.cpp
../FindReplaceDialog.ui
../ImageViewer.cpp
../ImageViewer.ui
../FileDialog.cpp
../SqlUiLexer.cpp
../JsonbUtil.cpp
../Data.cpp
../Settings.cpp
../sqlitedb.cpp
../sqlitetablemodel.cpp
../RowLoader.cpp
../CipherSettings.cpp
../CipherDialog.cpp
../CipherDialog.ui
../CondFormat.cpp
../sql/sqlitetypes.cpp
../sql/Query.cpp
../sql/ObjectIdentifier.cpp
../sql/parser/ParserDriver.cpp
../sql/parser/sqlite3_lexer.cpp
../sql/parser/sqlite3_parser.cpp
)

set(TESTEDITDIALOG_HDR
TestEditDialog.h
)

add_executable(test-edit-dialog ${TESTEDITDIALOG_HDR} ${TESTEDITDIALOG_SRC})
target_include_directories(test-edit-dialog PRIVATE ${CMAKE_SOURCE_DIR}/libs/json)
target_link_libraries(test-edit-dialog ${QT_MAJOR}::Test ${QT_MAJOR}::Widgets ${QT_MAJOR}::Concurrent ${QT_MAJOR}::Xml ${QT_MAJOR}::PrintSupport
QHexEdit::QHexEdit QScintilla::QScintilla ${LIBSQLITE_NAME} ${QT5_COMPAT})
if(QT_MAJOR STREQUAL "Qt6")
# Same as for the main target: the .ui files connect to slots that only exist in Qt5 unless connections are by string
set_target_properties(test-edit-dialog PROPERTIES AUTOUIC_OPTIONS "--connections=string")
endif()
add_test(NAME test-edit-dialog COMMAND $<TARGET_FILE:test-edit-dialog>)
if(WIN32)
# Like test-global-filter, this needs to find the SQLite DLL built by the CI jobs
set_tests_properties(test-edit-dialog PROPERTIES ENVIRONMENT "PATH=${TESTGLOBALFILTER_PATH}")
endif()
115 changes: 115 additions & 0 deletions src/tests/TestEditDialog.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#include <QtTest/QTest>
#include <QApplication>
#include <QComboBox>
#include <QLabel>
#include <QToolButton>
#include <Qsci/qsciscintilla.h>
#include <sqlite3.h>

#include "TestEditDialog.h"
#include "../EditDialog.h"
#include "../docktextedit.h"

namespace
{
const int TextEditorMode = 0;
const int JsonEditorMode = 4;

// Let SQLite create the JSONB blob for us
QByteArray makeJsonb(const char* json)
{
sqlite3* db = nullptr;
sqlite3_open(":memory:", &db);
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db, "SELECT jsonb(?1)", -1, &stmt, nullptr);
sqlite3_bind_text(stmt, 1, json, -1, SQLITE_STATIC);
QByteArray result;
if(sqlite3_step(stmt) == SQLITE_ROW)
result = QByteArray(static_cast<const char*>(sqlite3_column_blob(stmt, 0)), sqlite3_column_bytes(stmt, 0));
sqlite3_finalize(stmt);
sqlite3_close(db);
return result;
}

// Shows data in the given editor mode like selecting a cell does
void showData(EditDialog& dialog, int mode, const QByteArray& data)
{
dialog.findChild<QComboBox*>("comboMode")->setCurrentIndex(mode);
QMetaObject::invokeMethod(&dialog, "loadData", Qt::DirectConnection, Q_ARG(QByteArray, data));
QMetaObject::invokeMethod(&dialog, "updateCellInfoAndMode", Qt::DirectConnection, Q_ARG(QByteArray, data));
}
}

void TestEditDialog::initTestCase()
{
// One dialog for all tests: the SQL editors share a lexer which is owned by the first editor that is created
dialog = new EditDialog();
dialog->setReadOnly(false);

// Stay in the mode the test selects, the automatic mode would switch to the hex editor for binary data
dialog->findChild<QToolButton*>("buttonAutoSwitchMode")->setChecked(false);
}

void TestEditDialog::cleanupTestCase()
{
delete dialog;
}

void TestEditDialog::jsonbIsShownAsReadOnlyJson()
{
if(sqlite3_libversion_number() < 3045000)
QSKIP("JSONB needs SQLite 3.45.0 or newer");

auto* editor = dialog->findChild<DockTextEdit*>();
QVERIFY(editor);

showData(*dialog, TextEditorMode, makeJsonb("{\"a\":[1,2]}"));
QCOMPARE(editor->text(), QString("{\"a\":[1,2]}"));
QVERIFY(editor->isReadOnly());
QVERIFY(editor->isEnabled());
QVERIFY(dialog->findChild<QLabel*>("labelInfo")->text().contains("JSONB"));

// The JSON mode shows it formatted, if that is enabled in the settings (the default)
showData(*dialog, JsonEditorMode, makeJsonb("{\"a\":[1,2]}"));
QVERIFY(editor->text().contains("\"a\""));
QVERIFY(editor->text().contains("1"));
QVERIFY(editor->isReadOnly());
}

void TestEditDialog::editorBecomesWritableAgainAfterJsonb()
{
if(sqlite3_libversion_number() < 3045000)
QSKIP("JSONB needs SQLite 3.45.0 or newer");

auto* editor = dialog->findChild<DockTextEdit*>();
QVERIFY(editor);

showData(*dialog, TextEditorMode, makeJsonb("[1,2]"));
QVERIFY(editor->isReadOnly());

showData(*dialog, TextEditorMode, "some text");
QCOMPARE(editor->text(), QString("some text"));
QVERIFY(!editor->isReadOnly());
}

void TestEditDialog::otherBinaryDataIsNotShownAsText()
{
auto* editor = dialog->findChild<DockTextEdit*>();
QVERIFY(editor);

showData(*dialog, TextEditorMode, QByteArray("\x00\x01\x02\xff", 4));
QVERIFY(editor->text().contains("Binary data can't be viewed"));
QVERIFY(!editor->isEnabled());
QVERIFY(!dialog->findChild<QLabel*>("labelInfo")->text().contains("JSONB"));
}

int main(int argc, char* argv[])
{
// The dialog needs a QApplication, but the tests must also run on machines without a display (e.g. CI)
if(qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM"))
qputenv("QT_QPA_PLATFORM", "offscreen");

QApplication app(argc, argv);
TestEditDialog test;
return QTest::qExec(&test, argc, argv);
}
23 changes: 23 additions & 0 deletions src/tests/TestEditDialog.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#ifndef TESTEDITDIALOG_H
#define TESTEDITDIALOG_H

#include <QObject>

class EditDialog;

class TestEditDialog : public QObject
{
Q_OBJECT

private slots:
void initTestCase();
void cleanupTestCase();
void jsonbIsShownAsReadOnlyJson();
void editorBecomesWritableAgainAfterJsonb();
void otherBinaryDataIsNotShownAsText();

private:
EditDialog* dialog = nullptr;
};

#endif // TESTEDITDIALOG_H
Loading
Loading