From a630b50a8f824928546a3d9d0c2d4949ca3d3afc Mon Sep 17 00:00:00 2001 From: John Knox Date: Tue, 28 May 2019 10:10:31 -0700 Subject: [PATCH] Add databases plugin v0 (android) (#441) Summary: Adds a plugin for listing the databases, tables and contents of those tables in an android app. Right now, works with sqlite, but it should be generic enough to work with other db types. ## Changelog Add initial version of android databases plugin Creating a PR, I may need to do some cleaning up, but this is to kick off that process. Pull Request resolved: https://github.com/facebook/flipper/pull/441 Reviewed By: danielbuechele Differential Revision: D15288831 Pulled By: jknoxville fbshipit-source-id: 6379ad60d640ea6b0a9473acc03dd6ea81a3a8d4 --- android/build.gradle | 3 + .../flipper/sample/FlipperInitializer.java | 6 +- .../flipper/sample/Database1Helper.java | 84 ++ .../flipper/sample/Database2Helper.java | 71 ++ .../sample/FlipperSampleApplication.java | 11 +- .../plugins/databases/DatabaseDescriptor.java | 15 + .../plugins/databases/DatabaseDriver.java | 148 ++++ .../databases/DatabasesErrorCodes.java | 16 + .../databases/DatabasesFlipperPlugin.java | 53 ++ .../plugins/databases/DatabasesManager.java | 260 ++++++ .../plugins/databases/ObjectMapper.java | 226 ++++++ .../SqliteDatabaseConnectionProvider.java | 16 + .../databases/impl/SqliteDatabaseDriver.java | 353 +++++++++ .../impl/SqliteDatabaseProvider.java | 15 + .../databases/DatabasesFlipperPluginTest.java | 492 ++++++++++++ .../__snapshots__/App.electron.js.snap | 6 +- .../__snapshots__/TitleBar.electron.js.snap | 6 +- src/plugins/databases/ButtonNavigation.js | 45 ++ src/plugins/databases/ClientProtocol.js | 70 ++ src/plugins/databases/index.js | 744 ++++++++++++++++++ src/plugins/databases/package.json | 15 + src/plugins/databases/yarn.lock | 8 + src/ui/components/Button.js | 20 +- src/ui/components/table/ManagedTable.js | 1 + src/ui/components/table/TableHead.js | 3 +- .../table/TypeBasedValueRenderer.js | 68 ++ src/ui/index.js | 4 +- 27 files changed, 2738 insertions(+), 21 deletions(-) create mode 100644 android/sample/src/main/java/com/facebook/flipper/sample/Database1Helper.java create mode 100644 android/sample/src/main/java/com/facebook/flipper/sample/Database2Helper.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/DatabaseDescriptor.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/DatabaseDriver.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesErrorCodes.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesFlipperPlugin.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesManager.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/ObjectMapper.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseConnectionProvider.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseDriver.java create mode 100644 android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseProvider.java create mode 100644 android/src/test/java/com/facebook/flipper/plugins/databases/DatabasesFlipperPluginTest.java create mode 100644 src/plugins/databases/ButtonNavigation.js create mode 100644 src/plugins/databases/ClientProtocol.js create mode 100644 src/plugins/databases/index.js create mode 100644 src/plugins/databases/package.json create mode 100644 src/plugins/databases/yarn.lock create mode 100644 src/ui/components/table/TypeBasedValueRenderer.js diff --git a/android/build.gradle b/android/build.gradle index 7d10878c0..28ec5c5c5 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -11,6 +11,7 @@ apply plugin: 'maven' android { compileSdkVersion rootProject.compileSdkVersion buildToolsVersion rootProject.buildToolsVersion + testOptions.unitTests.includeAndroidResources = true defaultConfig { minSdkVersion rootProject.minSdkVersion @@ -67,6 +68,8 @@ android { testImplementation deps.mockito testImplementation deps.robolectric + testImplementation deps.testCore + testImplementation deps.testRules testImplementation deps.hamcrest testImplementation deps.junit } diff --git a/android/sample/src/debug/java/com/facebook/flipper/sample/FlipperInitializer.java b/android/sample/src/debug/java/com/facebook/flipper/sample/FlipperInitializer.java index da6e5385d..199823c39 100644 --- a/android/sample/src/debug/java/com/facebook/flipper/sample/FlipperInitializer.java +++ b/android/sample/src/debug/java/com/facebook/flipper/sample/FlipperInitializer.java @@ -1,14 +1,15 @@ /** * Copyright (c) Facebook, Inc. and its affiliates. * - * This source code is licensed under the MIT license found in the LICENSE - * file in the root directory of this source tree. + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. */ package com.facebook.flipper.sample; import android.content.Context; import com.facebook.flipper.core.FlipperClient; import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin; +import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin; import com.facebook.flipper.plugins.example.ExampleFlipperPlugin; import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin; import com.facebook.flipper.plugins.inspector.DescriptorMapping; @@ -51,6 +52,7 @@ public final class FlipperInitializer { client.addPlugin(new FrescoFlipperPlugin()); client.addPlugin(new ExampleFlipperPlugin()); client.addPlugin(CrashReporterPlugin.getInstance()); + client.addPlugin(new DatabasesFlipperPlugin(context)); client.start(); final OkHttpClient okHttpClient = diff --git a/android/sample/src/main/java/com/facebook/flipper/sample/Database1Helper.java b/android/sample/src/main/java/com/facebook/flipper/sample/Database1Helper.java new file mode 100644 index 000000000..e43d929d7 --- /dev/null +++ b/android/sample/src/main/java/com/facebook/flipper/sample/Database1Helper.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.sample; + +import android.content.ContentValues; +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +public class Database1Helper extends SQLiteOpenHelper { + + // If you change the database schema, you must increment the database version. + public static final int DATABASE_VERSION = 4; + private static final String SQL_CREATE_FIRST_TABLE = + "CREATE TABLE " + + "db1_first_table" + + " (" + + "_id INTEGER PRIMARY KEY," + + "db1_col0_text TEXT," + + "db1_col1_integer INTEGER," + + "db1_col2_float FLOAT," + + "db1_col3_blob TEXT," + + "db1_col4_null TEXT DEFAULT NULL," + + "db1_col5 TEXT," + + "db1_col6 TEXT," + + "db1_col7 TEXT," + + "db1_col8 TEXT," + + "db1_col9 TEXT" + + ")"; + private static final String SQL_CREATE_SECOND_TABLE = + "CREATE TABLE " + + "db1_empty_table" + + " (" + + "_id INTEGER PRIMARY KEY," + + "column1 TEXT," + + "column2 TEXT)"; + + public Database1Helper(Context context) { + super(context, "database1.db", null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(SQL_CREATE_FIRST_TABLE); + db.execSQL(SQL_CREATE_SECOND_TABLE); + insertSampleData(db); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // This database is only a cache for online data, so its upgrade policy is + // to simply to discard the data and start over + db.execSQL("DROP TABLE IF EXISTS first_table"); + db.execSQL("DROP TABLE IF EXISTS second_table"); + db.execSQL("DROP TABLE IF EXISTS db1_first_table"); + db.execSQL("DROP TABLE IF EXISTS db1_empty_table"); + onCreate(db); + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + onUpgrade(db, oldVersion, newVersion); + } + + public void insertSampleData(SQLiteDatabase db) { + for (int i = 0; i < 100; i++) { + ContentValues contentValues = new ContentValues(); + contentValues.put("db1_col0_text", "Long text data for testing resizing"); + contentValues.put("db1_col1_integer", 1000 + i); + contentValues.put("db1_col2_float", 1000.465f + i); + contentValues.put("db1_col3_blob", new byte[] {0, 0, 0, 1, 1, 0, 1, 1}); + contentValues.put("db1_col5", "db_1_column5_value"); + contentValues.put("db1_col6", "db_1_column6_value"); + contentValues.put("db1_col7", "db_1_column7_value"); + contentValues.put("db1_col8", "db_1_column8_value"); + contentValues.put("db1_col9", "db_1_column9_value"); + db.insert("db1_first_table", null, contentValues); + } + } +} diff --git a/android/sample/src/main/java/com/facebook/flipper/sample/Database2Helper.java b/android/sample/src/main/java/com/facebook/flipper/sample/Database2Helper.java new file mode 100644 index 000000000..ce6315d52 --- /dev/null +++ b/android/sample/src/main/java/com/facebook/flipper/sample/Database2Helper.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.sample; + +import android.content.ContentValues; +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +public class Database2Helper extends SQLiteOpenHelper { + + // If you change the database schema, you must increment the database version. + public static final int DATABASE_VERSION = 4; + private static final String SQL_CREATE_FIRST_TABLE = + "CREATE TABLE " + + "db2_first_table" + + " (" + + "_id INTEGER PRIMARY KEY," + + "column1 TEXT," + + "column2 TEXT)"; + private static final String SQL_CREATE_SECOND_TABLE = + "CREATE TABLE " + + "db2_second_table" + + " (" + + "_id INTEGER PRIMARY KEY," + + "column1 TEXT," + + "column2 TEXT)"; + + public Database2Helper(Context context) { + super(context, "database2.db", null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(SQL_CREATE_FIRST_TABLE); + db.execSQL(SQL_CREATE_SECOND_TABLE); + insertSampleData(db); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // This database is only a cache for online data, so its upgrade policy is + // to simply to discard the data and start over + db.execSQL("DROP TABLE IF EXISTS first_table"); + db.execSQL("DROP TABLE IF EXISTS second_table"); + db.execSQL("DROP TABLE IF EXISTS db2_first_table"); + db.execSQL("DROP TABLE IF EXISTS db2_second_table"); + onCreate(db); + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + onUpgrade(db, oldVersion, newVersion); + } + + public void insertSampleData(SQLiteDatabase db) { + for (int i = 0; i < 10; i++) { + ContentValues contentValues = new ContentValues(); + contentValues.put("column1", "Long text data for testing resizing"); + contentValues.put( + "column2", + "extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra extra Long text data for testing resizing"); + db.insert("db2_first_table", null, contentValues); + db.insert("db2_second_table", null, contentValues); + } + } +} diff --git a/android/sample/src/main/java/com/facebook/flipper/sample/FlipperSampleApplication.java b/android/sample/src/main/java/com/facebook/flipper/sample/FlipperSampleApplication.java index 7fd2786c0..b9a90c3f6 100644 --- a/android/sample/src/main/java/com/facebook/flipper/sample/FlipperSampleApplication.java +++ b/android/sample/src/main/java/com/facebook/flipper/sample/FlipperSampleApplication.java @@ -1,13 +1,14 @@ /** * Copyright (c) Facebook, Inc. and its affiliates. * - * This source code is licensed under the MIT license found in the LICENSE - * file in the root directory of this source tree. + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. */ package com.facebook.flipper.sample; import android.app.Application; import android.content.Context; +import android.database.DatabaseUtils; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.flipper.android.AndroidFlipperClient; import com.facebook.flipper.core.FlipperClient; @@ -33,5 +34,11 @@ public class FlipperSampleApplication extends Application { .edit() .putInt("SomeKey", 1337) .apply(); + + Database1Helper db1Helper = new Database1Helper(this); + Database2Helper db2Helper = new Database2Helper(this); + + DatabaseUtils.queryNumEntries(db1Helper.getReadableDatabase(), "db1_first_table", null, null); + DatabaseUtils.queryNumEntries(db2Helper.getReadableDatabase(), "db2_first_table", null, null); } } diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/DatabaseDescriptor.java b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabaseDescriptor.java new file mode 100644 index 000000000..28a6def0a --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabaseDescriptor.java @@ -0,0 +1,15 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases; + +/** + * Interface to describe a Database object. The DatabaseDescriptor#name() is visible and displayed + * to the user + */ +public interface DatabaseDescriptor { + String name(); +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/DatabaseDriver.java b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabaseDriver.java new file mode 100644 index 000000000..bd0dad0b8 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabaseDriver.java @@ -0,0 +1,148 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases; + +import android.content.Context; +import androidx.annotation.StringDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; + +/** + * Abstract class allowing to implement different drivers interfacing with Databases. + * + * @param A DatabaseDescriptor object that is called for each databases provider by the + * driver + */ +public abstract class DatabaseDriver { + + private final Context mContext; + + public DatabaseDriver(final Context context) { + mContext = context; + } + + public Context getContext() { + return mContext; + } + + public abstract List getDatabases(); + + public abstract List getTableNames(DESCRIPTOR databaseDescriptor); + + public abstract DatabaseGetTableDataResponse getTableData( + DESCRIPTOR databaseDescriptor, + String table, + String order, + boolean reverse, + int start, + int count); + + public abstract DatabaseGetTableStructureResponse getTableStructure( + DESCRIPTOR databaseDescriptor, String table); + + public abstract DatabaseExecuteSqlResponse executeSQL( + DESCRIPTOR databaseDescriptor, String query); + + public static class DatabaseGetTableDataResponse { + + public final List columns; + public final List> values; + public final Integer start; + public final Integer count; + public final Long total; + + public DatabaseGetTableDataResponse( + final List columns, + final List> values, + int start, + int count, + long total) { + this.columns = columns; + this.values = values; + this.start = start; + this.count = count; + this.total = total; + } + } + + public static class DatabaseGetTableStructureResponse { + + public final List structureColumns; + public final List> structureValues; + public final List indexesColumns; + public final List> indexesValues; + public final String definition; + + public DatabaseGetTableStructureResponse( + final List structureColumns, + final List> structureValues, + final List indexesColumns, + final List> indexesValues, + String definition) { + this.structureColumns = structureColumns; + this.structureValues = structureValues; + this.indexesColumns = indexesColumns; + this.indexesValues = indexesValues; + this.definition = definition; + } + } + + public static class DatabaseExecuteSqlResponse { + + @Retention(RetentionPolicy.SOURCE) + @StringDef({TYPE_SELECT, TYPE_INSERT, TYPE_UPDATE_DELETE, TYPE_RAW}) + public @interface Type {} + + public static final String TYPE_SELECT = "select"; + public static final String TYPE_INSERT = "insert"; + public static final String TYPE_UPDATE_DELETE = "update_delete"; + public static final String TYPE_RAW = "raw"; + + public final @Type String type; + + // Select + public final List columns; + public final List> values; + + // insert + public final Long insertedId; + + // update/delete + public final Integer affectedCount; + + private DatabaseExecuteSqlResponse( + final @Type String type, + final List columns, + final List> values, + Long insertedId, + Integer affectedCount) { + this.type = type; + this.columns = columns; + this.values = values; + this.insertedId = insertedId; + this.affectedCount = affectedCount; + } + + public static DatabaseExecuteSqlResponse successfulSelect( + List columns, List> values) { + return new DatabaseExecuteSqlResponse(TYPE_SELECT, columns, values, null, null); + } + + public static DatabaseExecuteSqlResponse successfulInsert(long insertedId) { + return new DatabaseExecuteSqlResponse(TYPE_INSERT, null, null, insertedId, null); + } + + public static DatabaseExecuteSqlResponse successfulUpdateDelete(int affectedRows) { + return new DatabaseExecuteSqlResponse(TYPE_UPDATE_DELETE, null, null, null, affectedRows); + } + + public static DatabaseExecuteSqlResponse successfulRawQuery() { + return new DatabaseExecuteSqlResponse(TYPE_RAW, null, null, null, null); + } + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesErrorCodes.java b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesErrorCodes.java new file mode 100644 index 000000000..218c14f8f --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesErrorCodes.java @@ -0,0 +1,16 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases; + +public class DatabasesErrorCodes { + + public static final int ERROR_INVALID_REQUEST = 1; + public static final String ERROR_INVALID_REQUEST_MESSAGE = "The request received was invalid"; + public static final int ERROR_DATABASE_INVALID = 2; + public static final String ERROR_DATABASE_INVALID_MESSAGE = "Could not access database"; + public static final int ERROR_SQL_EXECUTION_EXCEPTION = 3; +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesFlipperPlugin.java b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesFlipperPlugin.java new file mode 100644 index 000000000..9768518b6 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesFlipperPlugin.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases; + +import android.content.Context; +import com.facebook.flipper.core.FlipperConnection; +import com.facebook.flipper.core.FlipperPlugin; +import com.facebook.flipper.plugins.databases.impl.SqliteDatabaseDriver; +import java.util.Collections; +import java.util.List; + +public class DatabasesFlipperPlugin implements FlipperPlugin { + + private static final String ID = "Databases"; + + private final DatabasesManager databasesManager; + + public DatabasesFlipperPlugin(Context context) { + this(new SqliteDatabaseDriver(context)); + } + + public DatabasesFlipperPlugin(DatabaseDriver databaseDriver) { + this(Collections.singletonList(databaseDriver)); + } + + public DatabasesFlipperPlugin(List databaseDriverList) { + databasesManager = new DatabasesManager(databaseDriverList); + } + + @Override + public String getId() { + return ID; + } + + @Override + public void onConnect(FlipperConnection connection) { + databasesManager.setConnection(connection); + } + + @Override + public void onDisconnect() { + databasesManager.setConnection(null); + } + + @Override + public boolean runInBackground() { + return false; + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesManager.java b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesManager.java new file mode 100644 index 000000000..f309be15d --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/DatabasesManager.java @@ -0,0 +1,260 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases; + +import android.util.SparseArray; +import com.facebook.flipper.core.FlipperArray; +import com.facebook.flipper.core.FlipperConnection; +import com.facebook.flipper.core.FlipperObject; +import com.facebook.flipper.core.FlipperReceiver; +import com.facebook.flipper.core.FlipperResponder; +import com.facebook.flipper.plugins.databases.DatabaseDriver.DatabaseExecuteSqlResponse; +import com.facebook.flipper.plugins.databases.DatabaseDriver.DatabaseGetTableDataResponse; +import com.facebook.flipper.plugins.databases.DatabaseDriver.DatabaseGetTableStructureResponse; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import javax.annotation.Nullable; + +public class DatabasesManager { + + private static final String DATABASE_LIST_COMMAND = "databaseList"; + private static final String GET_TABLE_DATA_COMMAND = "getTableData"; + private static final String GET_TABLE_STRUCTURE_COMMAND = "getTableStructure"; + private static final String EXECUTE_COMMAND = "execute"; + + private final List mDatabaseDriverList; + private final SparseArray mDatabaseDescriptorHolderSparseArray; + private final Set mDatabaseDescriptorHolderSet; + + private FlipperConnection mConnection; + + public DatabasesManager(List databaseDriverList) { + this.mDatabaseDriverList = databaseDriverList; + this.mDatabaseDescriptorHolderSparseArray = new SparseArray<>(); + this.mDatabaseDescriptorHolderSet = + new TreeSet<>( + new Comparator() { + @Override + public int compare(DatabaseDescriptorHolder o1, DatabaseDescriptorHolder o2) { + return o1.databaseDescriptor.name().compareTo(o2.databaseDescriptor.name()); + } + }); + } + + public void setConnection(@Nullable FlipperConnection connection) { + this.mConnection = connection; + if (connection != null) { + listenForCommands(connection); + } + } + + public boolean isConnected() { + return mConnection != null; + } + + private void listenForCommands(FlipperConnection connection) { + connection.receive( + DATABASE_LIST_COMMAND, + new FlipperReceiver() { + @Override + public void onReceive(FlipperObject params, FlipperResponder responder) { + int databaseId = 1; + mDatabaseDescriptorHolderSparseArray.clear(); + mDatabaseDescriptorHolderSet.clear(); + for (DatabaseDriver databaseDriver : mDatabaseDriverList) { + List databaseDescriptorList = + databaseDriver.getDatabases(); + for (DatabaseDescriptor databaseDescriptor : databaseDescriptorList) { + int id = databaseId++; + DatabaseDescriptorHolder databaseDescriptorHolder = + new DatabaseDescriptorHolder(id, databaseDriver, databaseDescriptor); + mDatabaseDescriptorHolderSparseArray.put(id, databaseDescriptorHolder); + mDatabaseDescriptorHolderSet.add(databaseDescriptorHolder); + } + } + FlipperArray result = + ObjectMapper.databaseListToFlipperArray(mDatabaseDescriptorHolderSet); + responder.success(result); + } + }); + connection.receive( + GET_TABLE_DATA_COMMAND, + new FlipperReceiver() { + @Override + public void onReceive(FlipperObject params, FlipperResponder responder) { + GetTableDataRequest getTableDataRequest = + ObjectMapper.flipperObjectToGetTableDataRequest(params); + if (getTableDataRequest == null) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_INVALID_REQUEST, + DatabasesErrorCodes.ERROR_INVALID_REQUEST_MESSAGE)); + } else { + DatabaseDescriptorHolder databaseDescriptorHolder = + mDatabaseDescriptorHolderSparseArray.get(getTableDataRequest.databaseId); + if (databaseDescriptorHolder == null) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_DATABASE_INVALID, + DatabasesErrorCodes.ERROR_DATABASE_INVALID_MESSAGE)); + } else { + try { + DatabaseGetTableDataResponse databaseGetTableDataResponse = + databaseDescriptorHolder.databaseDriver.getTableData( + databaseDescriptorHolder.databaseDescriptor, + getTableDataRequest.table, + getTableDataRequest.order, + getTableDataRequest.reverse, + getTableDataRequest.start, + getTableDataRequest.count); + responder.success( + ObjectMapper.databaseGetTableDataReponseToFlipperObject( + databaseGetTableDataResponse)); + } catch (Exception e) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_SQL_EXECUTION_EXCEPTION, e.getMessage())); + } + } + } + } + }); + connection.receive( + GET_TABLE_STRUCTURE_COMMAND, + new FlipperReceiver() { + @Override + public void onReceive(FlipperObject params, FlipperResponder responder) { + GetTableStructureRequest getTableStructureRequest = + ObjectMapper.flipperObjectToGetTableStructureRequest(params); + if (getTableStructureRequest == null) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_INVALID_REQUEST, + DatabasesErrorCodes.ERROR_INVALID_REQUEST_MESSAGE)); + } else { + DatabaseDescriptorHolder databaseDescriptorHolder = + mDatabaseDescriptorHolderSparseArray.get(getTableStructureRequest.databaseId); + if (databaseDescriptorHolder == null) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_DATABASE_INVALID, + DatabasesErrorCodes.ERROR_DATABASE_INVALID_MESSAGE)); + } else { + try { + DatabaseGetTableStructureResponse databaseGetTableStructureResponse = + databaseDescriptorHolder.databaseDriver.getTableStructure( + databaseDescriptorHolder.databaseDescriptor, + getTableStructureRequest.table); + responder.success( + ObjectMapper.databaseGetTableStructureReponseToFlipperObject( + databaseGetTableStructureResponse)); + } catch (Exception e) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_SQL_EXECUTION_EXCEPTION, e.getMessage())); + } + } + } + } + }); + connection.receive( + EXECUTE_COMMAND, + new FlipperReceiver() { + @Override + public void onReceive(FlipperObject params, FlipperResponder responder) { + ExecuteSqlRequest executeSqlRequest = + ObjectMapper.flipperObjectToExecuteSqlRequest(params); + if (executeSqlRequest == null) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_INVALID_REQUEST, + DatabasesErrorCodes.ERROR_INVALID_REQUEST_MESSAGE)); + } else { + DatabaseDescriptorHolder databaseDescriptorHolder = + mDatabaseDescriptorHolderSparseArray.get(executeSqlRequest.databaseId); + if (databaseDescriptorHolder == null) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_DATABASE_INVALID, + DatabasesErrorCodes.ERROR_DATABASE_INVALID_MESSAGE)); + } else { + try { + DatabaseExecuteSqlResponse databaseExecuteSqlResponse = + databaseDescriptorHolder.databaseDriver.executeSQL( + databaseDescriptorHolder.databaseDescriptor, executeSqlRequest.value); + responder.success( + ObjectMapper.databaseExecuteSqlResponseToFlipperObject( + databaseExecuteSqlResponse)); + } catch (Exception e) { + responder.error( + ObjectMapper.toErrorFlipperObject( + DatabasesErrorCodes.ERROR_SQL_EXECUTION_EXCEPTION, e.getMessage())); + } + } + } + } + }); + } + + static class DatabaseDescriptorHolder { + + public final int id; + public final DatabaseDriver databaseDriver; + public final DatabaseDescriptor databaseDescriptor; + + public DatabaseDescriptorHolder( + int id, DatabaseDriver databaseDriver, DatabaseDescriptor databaseDescriptor) { + this.id = id; + this.databaseDriver = databaseDriver; + this.databaseDescriptor = databaseDescriptor; + } + } + + static class ExecuteSqlRequest { + + public final int databaseId; + public final String value; + + ExecuteSqlRequest(int databaseId, String value) { + this.databaseId = databaseId; + this.value = value; + } + } + + static class GetTableDataRequest { + + public final int databaseId; + public final String table; + public final String order; + public final boolean reverse; + public final int start; + public final int count; + + GetTableDataRequest( + int databaseId, String table, String order, boolean reverse, int start, int count) { + this.databaseId = databaseId; + this.table = table; + this.order = order; + this.reverse = reverse; + this.start = start; + this.count = count; + } + } + + static class GetTableStructureRequest { + + public final int databaseId; + public final String table; + + GetTableStructureRequest(int databaseId, String table) { + this.databaseId = databaseId; + this.table = table; + } + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/ObjectMapper.java b/android/src/main/java/com/facebook/flipper/plugins/databases/ObjectMapper.java new file mode 100644 index 000000000..00160010d --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/ObjectMapper.java @@ -0,0 +1,226 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases; + +import android.text.TextUtils; +import com.facebook.flipper.core.FlipperArray; +import com.facebook.flipper.core.FlipperArray.Builder; +import com.facebook.flipper.core.FlipperObject; +import com.facebook.flipper.plugins.databases.DatabaseDriver.DatabaseExecuteSqlResponse; +import com.facebook.flipper.plugins.databases.DatabaseDriver.DatabaseGetTableDataResponse; +import com.facebook.flipper.plugins.databases.DatabaseDriver.DatabaseGetTableStructureResponse; +import com.facebook.flipper.plugins.databases.DatabasesManager.DatabaseDescriptorHolder; +import com.facebook.flipper.plugins.databases.DatabasesManager.ExecuteSqlRequest; +import com.facebook.flipper.plugins.databases.DatabasesManager.GetTableDataRequest; +import com.facebook.flipper.plugins.databases.DatabasesManager.GetTableStructureRequest; +import java.io.UnsupportedEncodingException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public class ObjectMapper { + + private static final int MAX_BLOB_LENGTH = 512; + private static final String UNKNOWN_BLOB_LABEL = "{blob}"; + + public static FlipperArray databaseListToFlipperArray( + Collection databaseDescriptorHolderList) { + FlipperArray.Builder builder = new FlipperArray.Builder(); + + for (DatabaseDescriptorHolder databaseDescriptorHolder : databaseDescriptorHolderList) { + + List tableNameList = + databaseDescriptorHolder.databaseDriver.getTableNames( + databaseDescriptorHolder.databaseDescriptor); + Collections.sort(tableNameList); + FlipperArray.Builder tableBuilder = new Builder(); + for (String tablename : tableNameList) { + tableBuilder.put(tablename); + } + + builder.put( + new FlipperObject.Builder() + .put("id", databaseDescriptorHolder.id) + .put("name", databaseDescriptorHolder.databaseDescriptor.name()) + .put("tables", tableBuilder.build()) + .build()); + } + + return builder.build(); + } + + public static GetTableDataRequest flipperObjectToGetTableDataRequest(FlipperObject params) { + int databaseId = params.getInt("databaseId"); + String table = params.getString("table"); + String order = params.getString("order"); + boolean reverse = params.getBoolean("reverse"); + int start = params.getInt("start"); + int count = params.getInt("count"); + if (databaseId <= 0 || TextUtils.isEmpty(table)) { + return null; + } + return new GetTableDataRequest(databaseId, table, order, reverse, start, count); + } + + public static GetTableStructureRequest flipperObjectToGetTableStructureRequest( + FlipperObject params) { + int databaseId = params.getInt("databaseId"); + String table = params.getString("table"); + if (databaseId <= 0 || TextUtils.isEmpty(table)) { + return null; + } + return new GetTableStructureRequest(databaseId, table); + } + + public static ExecuteSqlRequest flipperObjectToExecuteSqlRequest(FlipperObject params) { + int databaseId = params.getInt("databaseId"); + String value = params.getString("value"); + if (databaseId <= 0 || TextUtils.isEmpty(value)) { + return null; + } + return new ExecuteSqlRequest(databaseId, value); + } + + public static FlipperObject databaseGetTableDataReponseToFlipperObject( + DatabaseGetTableDataResponse databaseGetTableDataResponse) { + + FlipperArray.Builder columnBuilder = new FlipperArray.Builder(); + for (String columnName : databaseGetTableDataResponse.columns) { + columnBuilder.put(columnName); + } + + FlipperArray.Builder rowBuilder = new FlipperArray.Builder(); + for (List row : databaseGetTableDataResponse.values) { + FlipperArray.Builder valueBuilder = new FlipperArray.Builder(); + for (Object item : row) { + valueBuilder.put(objectAndTypeToFlipperObject(item)); + } + rowBuilder.put(valueBuilder.build()); + } + + return new FlipperObject.Builder() + .put("columns", columnBuilder.build()) + .put("values", rowBuilder.build()) + .put("start", databaseGetTableDataResponse.start) + .put("count", databaseGetTableDataResponse.count) + .put("total", databaseGetTableDataResponse.total) + .build(); + } + + public static FlipperObject databaseGetTableStructureReponseToFlipperObject( + DatabaseGetTableStructureResponse databaseGetTableStructureResponse) { + + FlipperArray.Builder structureColumnBuilder = new FlipperArray.Builder(); + for (String columnName : databaseGetTableStructureResponse.structureColumns) { + structureColumnBuilder.put(columnName); + } + + FlipperArray.Builder indexesColumnBuilder = new FlipperArray.Builder(); + for (String columnName : databaseGetTableStructureResponse.indexesColumns) { + indexesColumnBuilder.put(columnName); + } + + FlipperArray.Builder structureValuesBuilder = new FlipperArray.Builder(); + for (List row : databaseGetTableStructureResponse.structureValues) { + FlipperArray.Builder valueBuilder = new FlipperArray.Builder(); + for (Object item : row) { + valueBuilder.put(objectAndTypeToFlipperObject(item)); + } + structureValuesBuilder.put(valueBuilder.build()); + } + + FlipperArray.Builder indexesValuesBuilder = new FlipperArray.Builder(); + for (List row : databaseGetTableStructureResponse.indexesValues) { + FlipperArray.Builder valueBuilder = new FlipperArray.Builder(); + for (Object item : row) { + valueBuilder.put(objectAndTypeToFlipperObject(item)); + } + indexesValuesBuilder.put(valueBuilder.build()); + } + + return new FlipperObject.Builder() + .put("structureColumns", structureColumnBuilder.build()) + .put("structureValues", structureValuesBuilder.build()) + .put("indexesColumns", indexesColumnBuilder.build()) + .put("indexesValues", indexesValuesBuilder.build()) + .put("definition", databaseGetTableStructureResponse.definition) + .build(); + } + + public static FlipperObject databaseExecuteSqlResponseToFlipperObject( + DatabaseExecuteSqlResponse databaseExecuteSqlResponse) { + + FlipperArray.Builder columnBuilder = new FlipperArray.Builder(); + for (String columnName : databaseExecuteSqlResponse.columns) { + columnBuilder.put(columnName); + } + + FlipperArray.Builder rowBuilder = new FlipperArray.Builder(); + for (List row : databaseExecuteSqlResponse.values) { + FlipperArray.Builder valueBuilder = new FlipperArray.Builder(); + for (Object item : row) { + valueBuilder.put(objectAndTypeToFlipperObject(item)); + } + rowBuilder.put(valueBuilder.build()); + } + + return new FlipperObject.Builder() + .put("type", databaseExecuteSqlResponse.type) + .put("columns", columnBuilder.build()) + .put("values", rowBuilder.build()) + .put("insertedId", databaseExecuteSqlResponse.insertedId) + .put("affectedCount", databaseExecuteSqlResponse.affectedCount) + .build(); + } + + private static FlipperObject objectAndTypeToFlipperObject(Object object) { + if (object == null) { + return new FlipperObject.Builder().put("type", "null").build(); + } else if (object instanceof Long) { + return new FlipperObject.Builder().put("type", "integer").put("value", object).build(); + } else if (object instanceof Double) { + return new FlipperObject.Builder().put("type", "float").put("value", object).build(); + } else if (object instanceof String) { + return new FlipperObject.Builder().put("type", "string").put("value", object).build(); + } else if (object instanceof byte[]) { + return new FlipperObject.Builder() + .put("type", "blob") + .put("value", blobToString((byte[]) object)) + .build(); + } else if (object instanceof Boolean) { + return new FlipperObject.Builder().put("type", "boolean").put("value", object).build(); + } else { + throw new IllegalArgumentException("type of Object is invalid"); + } + } + + private static String blobToString(byte[] blob) { + if (blob.length <= MAX_BLOB_LENGTH) { + if (fastIsAscii(blob)) { + try { + return new String(blob, "US-ASCII"); + } catch (UnsupportedEncodingException e) { + // Fall through... + } + } + } + return UNKNOWN_BLOB_LABEL; + } + + private static boolean fastIsAscii(byte[] blob) { + for (byte b : blob) { + if ((b & ~0x7f) != 0) { + return false; + } + } + return true; + } + + public static FlipperObject toErrorFlipperObject(int code, String message) { + return new FlipperObject.Builder().put("code", code).put("message", message).build(); + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseConnectionProvider.java b/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseConnectionProvider.java new file mode 100644 index 000000000..1777c24b7 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseConnectionProvider.java @@ -0,0 +1,16 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases.impl; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import java.io.File; + +public interface SqliteDatabaseConnectionProvider { + + SQLiteDatabase openDatabase(File databaseFile) throws SQLiteException; +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseDriver.java b/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseDriver.java new file mode 100644 index 000000000..fdb030947 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseDriver.java @@ -0,0 +1,353 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases.impl; + +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteStatement; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import com.facebook.flipper.plugins.databases.DatabaseDescriptor; +import com.facebook.flipper.plugins.databases.DatabaseDriver; +import com.facebook.flipper.plugins.databases.impl.SqliteDatabaseDriver.SqliteDatabaseDescriptor; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class SqliteDatabaseDriver extends DatabaseDriver { + + private static final String SCHEMA_TABLE = "sqlite_master"; + private static final String[] UNINTERESTING_FILENAME_SUFFIXES = + new String[] {"-journal", "-shm", "-uid", "-wal"}; + + private final SqliteDatabaseProvider sqliteDatabaseProvider; + private final SqliteDatabaseConnectionProvider sqliteDatabaseConnectionProvider; + + public SqliteDatabaseDriver(final Context context) { + this( + context, + new SqliteDatabaseProvider() { + @Override + public List getDatabaseFiles() { + List databaseFiles = new ArrayList<>(); + for (String databaseName : context.databaseList()) { + databaseFiles.add(context.getDatabasePath(databaseName)); + } + return databaseFiles; + } + }); + } + + public SqliteDatabaseDriver( + final Context context, final SqliteDatabaseProvider sqliteDatabaseProvider) { + this( + context, + sqliteDatabaseProvider, + new SqliteDatabaseConnectionProvider() { + @Override + public SQLiteDatabase openDatabase(File databaseFile) throws SQLiteException { + int flags = SQLiteDatabase.OPEN_READWRITE; + return SQLiteDatabase.openDatabase(databaseFile.getAbsolutePath(), null, flags); + } + }); + } + + public SqliteDatabaseDriver( + final Context context, + final SqliteDatabaseProvider sqliteDatabaseProvider, + final SqliteDatabaseConnectionProvider sqliteDatabaseConnectionProvider) { + super(context); + this.sqliteDatabaseProvider = sqliteDatabaseProvider; + this.sqliteDatabaseConnectionProvider = sqliteDatabaseConnectionProvider; + } + + @Override + public List getDatabases() { + ArrayList databases = new ArrayList<>(); + List potentialDatabaseFiles = sqliteDatabaseProvider.getDatabaseFiles(); + Collections.sort(potentialDatabaseFiles); + Iterable tidiedList = tidyDatabaseList(potentialDatabaseFiles); + for (File database : tidiedList) { + databases.add(new SqliteDatabaseDescriptor(database)); + } + return databases; + } + + @Override + public List getTableNames(SqliteDatabaseDescriptor databaseDescriptor) { + SQLiteDatabase database = + sqliteDatabaseConnectionProvider.openDatabase(databaseDescriptor.file); + try { + Cursor cursor = + database.rawQuery( + "SELECT name FROM " + SCHEMA_TABLE + " WHERE type IN (?, ?)", + new String[] {"table", "view"}); + try { + List tableNames = new ArrayList<>(); + while (cursor.moveToNext()) { + tableNames.add(cursor.getString(0)); + } + return tableNames; + } finally { + cursor.close(); + } + } finally { + database.close(); + } + } + + @Override + public DatabaseExecuteSqlResponse executeSQL( + SqliteDatabaseDescriptor databaseDescriptor, String query) { + SQLiteDatabase database = + sqliteDatabaseConnectionProvider.openDatabase(databaseDescriptor.file); + try { + String firstWordUpperCase = getFirstWord(query).toUpperCase(); + switch (firstWordUpperCase) { + case "UPDATE": + case "DELETE": + return executeUpdateDelete(database, query); + case "INSERT": + return executeInsert(database, query); + case "SELECT": + case "PRAGMA": + case "EXPLAIN": + return executeSelect(database, query); + default: + return executeRawQuery(database, query); + } + } finally { + database.close(); + } + } + + @Override + public DatabaseGetTableDataResponse getTableData( + SqliteDatabaseDescriptor databaseDescriptor, + String table, + @Nullable String order, + boolean reverse, + int start, + int count) { + SQLiteDatabase database = + sqliteDatabaseConnectionProvider.openDatabase(databaseDescriptor.file); + try { + String orderBy = order != null ? order + (reverse ? " DESC" : " ASC") : null; + String limitBy = start + ", " + count; + Cursor cursor = database.query(table, null, null, null, null, null, orderBy, limitBy); + long total = DatabaseUtils.queryNumEntries(database, table); + try { + String[] columnNames = cursor.getColumnNames(); + List> rows = cursorToList(cursor); + return new DatabaseGetTableDataResponse( + Arrays.asList(columnNames), rows, start, rows.size(), total); + } finally { + cursor.close(); + } + } finally { + database.close(); + } + } + + @Override + public DatabaseGetTableStructureResponse getTableStructure( + SqliteDatabaseDescriptor databaseDescriptor, String table) { + SQLiteDatabase database = + sqliteDatabaseConnectionProvider.openDatabase(databaseDescriptor.file); + try { + Cursor structureCursor = database.rawQuery("PRAGMA table_info(" + table + ")", null); + Cursor foreignKeysCursor = database.rawQuery("PRAGMA foreign_key_list(" + table + ")", null); + Cursor indexesCursor = database.rawQuery("PRAGMA index_list(" + table + ")", null); + Cursor definitionCursor = + database.rawQuery( + "SELECT sql FROM " + SCHEMA_TABLE + " WHERE name=?", new String[] {table}); + try { + // Structure & foreign keys + + List structureColumns = + Arrays.asList( + "column_name", "data_type", "nullable", "default", "primary_key", "foreign_key"); + List> structureValues = new ArrayList<>(); + Map foreignKeyValues = new HashMap<>(); + + while (foreignKeysCursor.moveToNext()) { + foreignKeyValues.put( + foreignKeysCursor.getString(foreignKeysCursor.getColumnIndex("from")), + foreignKeysCursor.getString(foreignKeysCursor.getColumnIndex("table")) + + "(" + + foreignKeysCursor.getString(foreignKeysCursor.getColumnIndex("to")) + + ")"); + } + + while (structureCursor.moveToNext()) { + + String columnName = structureCursor.getString(structureCursor.getColumnIndex("name")); + String foreignKey = + foreignKeyValues.containsKey(columnName) ? foreignKeyValues.get(columnName) : null; + + structureValues.add( + Arrays.asList( + columnName, + structureCursor.getString(structureCursor.getColumnIndex("type")), + structureCursor.getInt(structureCursor.getColumnIndex("notnull")) + == 0, // true if Nullable, false otherwise + getObjectFromColumnIndex( + structureCursor, structureCursor.getColumnIndex("dflt_value")), + structureCursor.getInt(structureCursor.getColumnIndex("pk")) == 1, + foreignKey)); + } + + // Indexes + + List indexesColumns = Arrays.asList("index_name", "unique", "indexed_column_name"); + List> indexesValues = new ArrayList<>(); + + while (indexesCursor.moveToNext()) { + List indexedColumnNames = new ArrayList<>(); + String indexName = indexesCursor.getString(indexesCursor.getColumnIndex("name")); + Cursor indexInfoCursor = database.rawQuery("PRAGMA index_info(" + indexName + ")", null); + try { + while (indexInfoCursor.moveToNext()) { + indexedColumnNames.add( + indexInfoCursor.getString(indexInfoCursor.getColumnIndex("name"))); + } + indexesValues.add( + Arrays.asList( + indexName, + indexesCursor.getInt(indexesCursor.getColumnIndex("unique")) == 1, + TextUtils.join(",", indexedColumnNames))); + } finally { + indexInfoCursor.close(); + } + } + + // Definition + + definitionCursor.moveToFirst(); + String definition = definitionCursor.getString(definitionCursor.getColumnIndex("sql")); + + return new DatabaseGetTableStructureResponse( + structureColumns, structureValues, indexesColumns, indexesValues, definition); + } finally { + structureCursor.close(); + foreignKeysCursor.close(); + indexesCursor.close(); + } + } finally { + database.close(); + } + } + + private static List tidyDatabaseList(List databaseFiles) { + Set originalAsSet = new HashSet<>(databaseFiles); + List tidiedList = new ArrayList<>(); + for (File databaseFile : databaseFiles) { + String databaseFilename = databaseFile.getPath(); + String sansSuffix = removeSuffix(databaseFilename, UNINTERESTING_FILENAME_SUFFIXES); + if (sansSuffix.equals(databaseFilename) || !originalAsSet.contains(new File(sansSuffix))) { + tidiedList.add(databaseFile); + } + } + return tidiedList; + } + + private static String removeSuffix(String str, String[] suffixesToRemove) { + for (String suffix : suffixesToRemove) { + if (str.endsWith(suffix)) { + return str.substring(0, str.length() - suffix.length()); + } + } + return str; + } + + private static String getFirstWord(String s) { + s = s.trim(); + int firstSpace = s.indexOf(' '); + return firstSpace >= 0 ? s.substring(0, firstSpace) : s; + } + + private static DatabaseExecuteSqlResponse executeUpdateDelete( + SQLiteDatabase database, String query) { + SQLiteStatement statement = database.compileStatement(query); + int count = statement.executeUpdateDelete(); + return DatabaseExecuteSqlResponse.successfulUpdateDelete(count); + } + + private static DatabaseExecuteSqlResponse executeInsert(SQLiteDatabase database, String query) { + SQLiteStatement statement = database.compileStatement(query); + long insertedId = statement.executeInsert(); + return DatabaseExecuteSqlResponse.successfulInsert(insertedId); + } + + private static DatabaseExecuteSqlResponse executeSelect(SQLiteDatabase database, String query) { + Cursor cursor = database.rawQuery(query, null); + try { + String[] columnNames = cursor.getColumnNames(); + List> rows = cursorToList(cursor); + return DatabaseExecuteSqlResponse.successfulSelect(Arrays.asList(columnNames), rows); + } finally { + cursor.close(); + } + } + + private static DatabaseExecuteSqlResponse executeRawQuery(SQLiteDatabase database, String query) { + database.execSQL(query); + return DatabaseExecuteSqlResponse.successfulRawQuery(); + } + + private static List> cursorToList(Cursor cursor) { + List> rows = new ArrayList<>(); + final int numColumns = cursor.getColumnCount(); + while (cursor.moveToNext()) { + List values = new ArrayList<>(); + for (int column = 0; column < numColumns; column++) { + values.add(getObjectFromColumnIndex(cursor, column)); + } + rows.add(values); + } + return rows; + } + + private static Object getObjectFromColumnIndex(Cursor cursor, int column) { + switch (cursor.getType(column)) { + case Cursor.FIELD_TYPE_NULL: + return null; + case Cursor.FIELD_TYPE_INTEGER: + return cursor.getLong(column); + case Cursor.FIELD_TYPE_FLOAT: + return cursor.getDouble(column); + case Cursor.FIELD_TYPE_BLOB: + return cursor.getBlob(column); + case Cursor.FIELD_TYPE_STRING: + default: + return cursor.getString(column); + } + } + + static class SqliteDatabaseDescriptor implements DatabaseDescriptor { + + public final File file; + + public SqliteDatabaseDescriptor(File file) { + this.file = file; + } + + @Override + public String name() { + return file.getName(); + } + } +} diff --git a/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseProvider.java b/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseProvider.java new file mode 100644 index 000000000..c76047a68 --- /dev/null +++ b/android/src/main/java/com/facebook/flipper/plugins/databases/impl/SqliteDatabaseProvider.java @@ -0,0 +1,15 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases.impl; + +import java.io.File; +import java.util.List; + +public interface SqliteDatabaseProvider { + + List getDatabaseFiles(); +} diff --git a/android/src/test/java/com/facebook/flipper/plugins/databases/DatabasesFlipperPluginTest.java b/android/src/test/java/com/facebook/flipper/plugins/databases/DatabasesFlipperPluginTest.java new file mode 100644 index 000000000..49fbe42f1 --- /dev/null +++ b/android/src/test/java/com/facebook/flipper/plugins/databases/DatabasesFlipperPluginTest.java @@ -0,0 +1,492 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + *

This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. + */ +package com.facebook.flipper.plugins.databases; + +import static org.hamcrest.Matchers.hasItem; +import static org.junit.Assert.assertThat; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import androidx.test.core.app.ApplicationProvider; +import com.facebook.flipper.core.FlipperArray; +import com.facebook.flipper.core.FlipperObject; +import com.facebook.flipper.plugins.databases.DatabaseDriver.DatabaseExecuteSqlResponse; +import com.facebook.flipper.plugins.databases.impl.SqliteDatabaseDriver; +import com.facebook.flipper.plugins.databases.impl.SqliteDatabaseProvider; +import com.facebook.flipper.testing.FlipperConnectionMock; +import com.facebook.flipper.testing.FlipperResponderMock; +import java.io.File; +import java.util.Arrays; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class DatabasesFlipperPluginTest { + + FlipperConnectionMock connectionMock; + FlipperResponderMock responderMock; + + DatabaseHelper databaseHelper1, databaseHelper2; + DatabasesFlipperPlugin plugin; + + @Before + public void setUp() { + connectionMock = new FlipperConnectionMock(); + responderMock = new FlipperResponderMock(); + + databaseHelper1 = + new DatabaseHelper(ApplicationProvider.getApplicationContext(), "database1.db"); + databaseHelper1 + .getWritableDatabase() + .execSQL("INSERT INTO first_table (column1, column2) VALUES('a','b')"); + databaseHelper2 = + new DatabaseHelper(ApplicationProvider.getApplicationContext(), "database2.db"); + databaseHelper2 + .getWritableDatabase() + .execSQL("INSERT INTO first_table (column1, column2) VALUES('a','b')"); + plugin = + new DatabasesFlipperPlugin( + new SqliteDatabaseDriver( + ApplicationProvider.getApplicationContext(), + new SqliteDatabaseProvider() { + @Override + public List getDatabaseFiles() { + return Arrays.asList( + ApplicationProvider.getApplicationContext() + .getDatabasePath(databaseHelper1.getDatabaseName()), + ApplicationProvider.getApplicationContext() + .getDatabasePath(databaseHelper2.getDatabaseName())); + } + })); + + plugin.onConnect(connectionMock); + } + + @After + public void tearDown() { + databaseHelper1.close(); + ApplicationProvider.getApplicationContext().deleteDatabase(databaseHelper1.getDatabaseName()); + ApplicationProvider.getApplicationContext().deleteDatabase(databaseHelper2.getDatabaseName()); + } + + @Test + public void testCommandDatabaseList() throws Exception { + // Arrange + + // Act + connectionMock.receivers.get("databaseList").onReceive(null, responderMock); + + // Assert + assertThat( + responderMock.successes, + hasItem( + new FlipperArray.Builder() + .put( + new FlipperObject.Builder() + .put("id", 1) + .put("name", databaseHelper1.getDatabaseName()) + .put( + "tables", + new FlipperArray.Builder() + .put("android_metadata") + .put("first_table") + .put("second_table") + .put("sqlite_sequence"))) + .put( + new FlipperObject.Builder() + .put("id", 2) + .put("name", databaseHelper2.getDatabaseName()) + .put( + "tables", + new FlipperArray.Builder() + .put("android_metadata") + .put("first_table") + .put("second_table") + .put("sqlite_sequence"))) + .build())); + } + + @Test + public void testCommandGetTableDataInvalidParams() throws Exception { + // Arrange + + // Act + connectionMock + .receivers + .get("getTableData") + .onReceive( + new FlipperObject.Builder() + .put("databaseId", -1) // Wrong id + .put("table", "") // invalid, can't be empty + .build(), + responderMock); + + // Assert + assertThat( + responderMock.errors, + hasItem( + new FlipperObject.Builder() + .put("code", DatabasesErrorCodes.ERROR_INVALID_REQUEST) + .put("message", DatabasesErrorCodes.ERROR_INVALID_REQUEST_MESSAGE) + .build())); + } + + @Test + public void testCommandGetTableDataInvalidDatabase() throws Exception { + // Arrange + + // Act + connectionMock + .receivers + .get("getTableData") + .onReceive( + new FlipperObject.Builder().put("databaseId", 10).put("table", "first_table").build(), + responderMock); + + // Assert + assertThat( + responderMock.errors, + hasItem( + new FlipperObject.Builder() + .put("code", DatabasesErrorCodes.ERROR_DATABASE_INVALID) + .put("message", DatabasesErrorCodes.ERROR_DATABASE_INVALID_MESSAGE) + .build())); + } + + @Test + public void testCommandGetTableData() throws Exception { + // Arrange + connectionMock.receivers.get("databaseList").onReceive(null, responderMock); // Load data + SQLiteDatabase db = databaseHelper1.getWritableDatabase(); + db.execSQL("INSERT INTO first_table (column1, column2) VALUES('c','d')"); + db.execSQL("INSERT INTO first_table (column1, column2) VALUES('e','f')"); + db.execSQL("INSERT INTO first_table (column1, column2) VALUES('g','h')"); + db.close(); + + // Act + connectionMock + .receivers + .get("getTableData") + .onReceive( + new FlipperObject.Builder() + .put("databaseId", 1) + .put("table", "first_table") + .put("order", "column2") + .put("reverse", true) + .put("start", 1) + .put("count", 2) + .build(), + responderMock); + + // Assert + assertThat( + responderMock.successes, + hasItem( + new FlipperObject.Builder() + .put( + "columns", + new FlipperArray.Builder().put("_id").put("column1").put("column2").build()) + .put( + "values", + new FlipperArray.Builder() + .put( + new FlipperArray.Builder() + .put( + new FlipperObject.Builder() + .put("type", "integer") + .put("value", 3)) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "e")) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "f")) + .build()) + .put( + new FlipperArray.Builder() + .put( + new FlipperObject.Builder() + .put("type", "integer") + .put("value", 2)) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "c")) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "d")) + .build()) + .build()) + .put("start", 1) + .put("count", 2) + .put("total", 4) + .build())); + } + + @Test + public void testCommandGetTableStructure() throws Exception { + // Arrange + connectionMock.receivers.get("databaseList").onReceive(null, responderMock); // Load data + SQLiteDatabase db = databaseHelper1.getWritableDatabase(); + db.execSQL("CREATE UNIQUE INDEX index_name ON first_table(column1, column2)"); + db.close(); + + // Act + connectionMock + .receivers + .get("getTableStructure") + .onReceive( + new FlipperObject.Builder().put("databaseId", 1).put("table", "first_table").build(), + responderMock); + + // Assert + assertThat( + responderMock.successes, + hasItem( + new FlipperObject.Builder() + .put( + "structureColumns", + new FlipperArray.Builder() + .put("column_name") + .put("data_type") + .put("nullable") + .put("default") + .put("primary_key") + .put("foreign_key") + .build()) + .put( + "structureValues", + new FlipperArray.Builder() + .put( + new FlipperArray.Builder() + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "_id")) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "INTEGER")) + .put( + new FlipperObject.Builder() + .put("type", "boolean") + .put("value", true)) + .put(new FlipperObject.Builder().put("type", "null")) + .put( + new FlipperObject.Builder() + .put("type", "boolean") + .put("value", true)) + .put(new FlipperObject.Builder().put("type", "null")) + .build()) + .put( + new FlipperArray.Builder() + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "column1")) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "TEXT")) + .put( + new FlipperObject.Builder() + .put("type", "boolean") + .put("value", true)) + .put(new FlipperObject.Builder().put("type", "null")) + .put( + new FlipperObject.Builder() + .put("type", "boolean") + .put("value", false)) + .put(new FlipperObject.Builder().put("type", "null")) + .build()) + .put( + new FlipperArray.Builder() + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "column2")) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "TEXT")) + .put( + new FlipperObject.Builder() + .put("type", "boolean") + .put("value", true)) + .put(new FlipperObject.Builder().put("type", "null")) + .put( + new FlipperObject.Builder() + .put("type", "boolean") + .put("value", false)) + .put(new FlipperObject.Builder().put("type", "null")) + .build()) + .build()) + .put( + "indexesColumns", + new FlipperArray.Builder() + .put("index_name") + .put("unique") + .put("indexed_column_name") + .build()) + .put( + "indexesValues", + new FlipperArray.Builder() + .put( + new FlipperArray.Builder() + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "index_name")) + .put( + new FlipperObject.Builder() + .put("type", "boolean") + .put("value", true)) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "column1,column2")) + .build()) + .build()) + .put( + "definition", + "CREATE TABLE first_table (_id INTEGER PRIMARY KEY AUTOINCREMENT,column1 TEXT,column2 TEXT)") + .build())); + } + + @Test + public void testCommandExecuteInvalidParams() throws Exception { + // Arrange + + // Act + connectionMock + .receivers + .get("execute") + .onReceive( + new FlipperObject.Builder() + .put("databaseId", 1) + .put("value", "") // invalid, can't be empty + .build(), + responderMock); + + // Assert + assertThat( + responderMock.errors, + hasItem( + new FlipperObject.Builder() + .put("code", DatabasesErrorCodes.ERROR_INVALID_REQUEST) + .put("message", DatabasesErrorCodes.ERROR_INVALID_REQUEST_MESSAGE) + .build())); + } + + @Test + public void testCommandExecuteInvalidDatabase() throws Exception { + // Arrange + + // Act + connectionMock + .receivers + .get("execute") + .onReceive( + new FlipperObject.Builder().put("databaseId", 10).put("value", "SELECT...").build(), + responderMock); + + // Assert + assertThat( + responderMock.errors, + hasItem( + new FlipperObject.Builder() + .put("code", DatabasesErrorCodes.ERROR_DATABASE_INVALID) + .put("message", DatabasesErrorCodes.ERROR_DATABASE_INVALID_MESSAGE) + .build())); + } + + @Test + public void testCommandExecute() throws Exception { + // Arrange + connectionMock.receivers.get("databaseList").onReceive(null, responderMock); // Load data + + // Act + connectionMock + .receivers + .get("execute") + .onReceive( + new FlipperObject.Builder() + .put("databaseId", 1) + .put("value", "SELECT column1,column2 FROM first_table") + .build(), + responderMock); + + // Assert + assertThat( + responderMock.successes, + hasItem( + new FlipperObject.Builder() + .put("type", DatabaseExecuteSqlResponse.TYPE_SELECT) + .put("columns", new FlipperArray.Builder().put("column1").put("column2").build()) + .put( + "values", + new FlipperArray.Builder() + .put( + new FlipperArray.Builder() + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "a")) + .put( + new FlipperObject.Builder() + .put("type", "string") + .put("value", "b")) + .build()) + .build()) + .build())); + } + + public static class DatabaseHelper extends SQLiteOpenHelper { + + // If you change the database schema, you must increment the database version. + public static final int DATABASE_VERSION = 1; + private static final String SQL_CREATE_FIRST_TABLE = + "CREATE TABLE first_table (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "column1 TEXT," + + "column2 TEXT)"; + private static final String SQL_CREATE_SECOND_TABLE = + "CREATE TABLE second_table (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "column1 TEXT," + + "column2 TEXT)"; + + public DatabaseHelper(Context context, String databaseName) { + super(context, databaseName, null, DATABASE_VERSION); + } + + public void onCreate(SQLiteDatabase db) { + db.execSQL(SQL_CREATE_FIRST_TABLE); + db.execSQL(SQL_CREATE_SECOND_TABLE); + } + + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // This database is only a cache for online data, so its upgrade policy is + // to simply to discard the data and start over + db.execSQL("DROP TABLE IF EXISTS first_table"); + db.execSQL("DROP TABLE IF EXISTS second_table"); + onCreate(db); + } + + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + onUpgrade(db, oldVersion, newVersion); + } + } +} diff --git a/src/__tests__/__snapshots__/App.electron.js.snap b/src/__tests__/__snapshots__/App.electron.js.snap index 745736e6e..956cee736 100644 --- a/src/__tests__/__snapshots__/App.electron.js.snap +++ b/src/__tests__/__snapshots__/App.electron.js.snap @@ -25,7 +25,7 @@ exports[`Empty app state matches snapshot 1`] = ` className="css-1ayt83l" >

void, + /** Callback when forwards button is clicked */ + onForward: () => void, +|}) { + return ( + + + + + ); +} diff --git a/src/plugins/databases/ClientProtocol.js b/src/plugins/databases/ClientProtocol.js new file mode 100644 index 000000000..a3cc680c4 --- /dev/null +++ b/src/plugins/databases/ClientProtocol.js @@ -0,0 +1,70 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type {PluginClient} from '../../plugin'; +import type {Value} from '../../ui/components/table/TypeBasedValueRenderer'; + +type ClientCall = Params => Promise; + +type DatabaseListRequest = {}; + +type DatabaseListResponse = Array<{ + id: number, + name: string, + tables: Array, +}>; + +type QueryTableRequest = { + databaseId: number, + table: string, + order?: string, + reverse: boolean, + start: number, + count: number, +}; + +type QueryTableResponse = { + columns: Array, + values: Array>, + start: number, + count: number, + total: number, +}; + +type GetTableStructureRequest = { + databaseId: number, + table: string, +}; + +type GetTableStructureResponse = { + structureColumns: Array, + structureValues: Array>, + indexesColumns: Array, + indexesValues: Array>, + definition: string, +}; + +export class DatabaseClient { + client: PluginClient; + + constructor(pluginClient: PluginClient) { + this.client = pluginClient; + } + + getDatabases: ClientCall< + DatabaseListRequest, + DatabaseListResponse, + > = params => this.client.call('databaseList', {}); + + getTableData: ClientCall = params => + this.client.call('getTableData', params); + + getTableStructure: ClientCall< + GetTableStructureRequest, + GetTableStructureResponse, + > = params => this.client.call('getTableStructure', params); +} diff --git a/src/plugins/databases/index.js b/src/plugins/databases/index.js new file mode 100644 index 000000000..bac6278da --- /dev/null +++ b/src/plugins/databases/index.js @@ -0,0 +1,744 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import { + styled, + Toolbar, + Select, + FlexColumn, + FlexRow, + ManagedTable, + Text, + Button, + ButtonGroup, + Input, +} from 'flipper'; +import {Component} from 'react'; +import type { + TableBodyRow, + TableRowSortOrder, +} from '../../ui/components/table/types'; +import {FlipperPlugin} from 'flipper'; +import {DatabaseClient} from './ClientProtocol'; +import {renderValue} from 'flipper'; +import type {Value} from 'flipper'; +import ButtonNavigation from './ButtonNavigation'; +import _ from 'lodash'; + +const PAGE_SIZE = 50; + +const BoldSpan = styled('Span')({ + fontSize: 12, + color: '#90949c', + fontWeight: 'bold', + textTransform: 'uppercase', +}); + +type DatabasesPluginState = {| + selectedDatabase: number, + selectedDatabaseTable: ?string, + pageRowNumber: number, + databases: Array, + viewMode: 'data' | 'structure', + error: ?null, + currentPage: ?Page, + currentStructure: ?Structure, + currentSort: ?TableRowSortOrder, +|}; + +type Page = { + databaseId: number, + table: string, + columns: Array, + rows: Array, + start: number, + count: number, + total: number, +}; + +type Structure = {| + databaseId: number, + table: string, + columns: Array, + rows: Array, + indexesColumns: Array, + indexesValues: Array, +|}; + +type Actions = + | SelectDatabaseEvent + | SelectDatabaseTableEvent + | UpdateDatabasesEvent + | UpdateViewModeEvent + | UpdatePageEvent + | UpdateStructureEvent + | NextPageEvent + | PreviousPageEvent + | RefreshEvent + | SortByChangedEvent + | GoToRowEvent; + +type DatabaseEntry = { + id: number, + name: string, + tables: Array, +}; + +type UpdateDatabasesEvent = {| + databases: Array<{name: string, id: number, tables: Array}>, + type: 'UpdateDatabases', +|}; + +type SelectDatabaseEvent = {| + type: 'UpdateSelectedDatabase', + database: number, +|}; + +type SelectDatabaseTableEvent = {| + type: 'UpdateSelectedDatabaseTable', + table: string, +|}; + +type UpdateViewModeEvent = {| + type: 'UpdateViewMode', + viewMode: 'data' | 'structure', +|}; + +type UpdatePageEvent = {| + type: 'UpdatePage', + databaseId: number, + table: string, + columns: Array, + values: Array>, + start: number, + count: number, + total: number, +|}; + +type UpdateStructureEvent = {| + type: 'UpdateStructure', + databaseId: number, + table: string, + columns: Array, + rows: Array>, + indexesColumns: Array, + indexesValues: Array>, +|}; + +type NextPageEvent = { + type: 'NextPage', +}; + +type PreviousPageEvent = { + type: 'PreviousPage', +}; + +type RefreshEvent = { + type: 'Refresh', +}; + +type SortByChangedEvent = { + type: 'SortByChanged', + sortOrder: TableRowSortOrder, +}; + +type GoToRowEvent = { + type: 'GoToRow', + row: number, +}; + +function transformRow( + columns: Array, + row: Array, + index: number, +): TableBodyRow { + const transformedColumns = {}; + for (var i = 0; i < columns.length; i++) { + transformedColumns[columns[i]] = {value: renderValue(row[i])}; + } + return {key: String(index), columns: transformedColumns}; +} + +function renderTable(page: ?Page, component: DatabasesPlugin) { + if (!page) { + return null; + } + return ( + ({ + key: name, + visible: true, + }))} + columns={page.columns.reduce((acc, val) => { + acc[val] = {value: val, resizable: true, sortable: true}; + return acc; + }, {})} + zebra={true} + rows={page.rows} + horizontallyScrollable={true} + onSort={(sortOrder: TableRowSortOrder) => { + component.dispatchAction({ + type: 'SortByChanged', + sortOrder, + }); + }} + initialSortOrder={component.state.currentSort} + /> + ); +} + +function renderDatabaseColumns(structure: ?Structure) { + if (!structure) { + return null; + } + return ( + + ({ + key: name, + visible: true, + }))} + columns={structure.columns.reduce((acc, val) => { + acc[val] = {value: val, resizable: true}; + return acc; + }, {})} + zebra={true} + rows={structure.rows || []} + horizontallyScrollable={true} + /> + + ); +} + +function renderDatabaseIndexes(structure: ?Structure) { + if (!structure) { + return null; + } + return ( + + ({ + key: name, + visible: true, + }))} + columns={structure.indexesColumns.reduce((acc, val) => { + acc[val] = {value: val, resizable: true}; + return acc; + }, {})} + zebra={true} + rows={structure.indexesValues || []} + horizontallyScrollable={true} + /> + + ); +} + +type PageInfoProps = { + currentRow: number, + count: number, + totalRows: number, + onChange: (currentRow: number, count: number) => void, +}; + +class PageInfo extends Component< + PageInfoProps, + {isOpen: boolean, inputValue: string}, +> { + constructor(props: PageInfoProps) { + super(props); + this.state = {isOpen: false, inputValue: String(props.currentRow)}; + } + + onOpen() { + this.setState({isOpen: true}); + } + + onInputChanged(e) { + this.setState({inputValue: e.target.value}); + } + + onSubmit(e: SyntheticKeyboardEvent<>) { + if (e.key === 'Enter') { + const rowNumber = parseInt(this.state.inputValue); + console.log(rowNumber); + this.props.onChange(rowNumber - 1, this.props.count); + this.setState({isOpen: false}); + } + } + + render() { + return ( + +
+ + {this.props.count === this.props.totalRows + ? `${this.props.count} ` + : `${this.props.currentRow + 1}-${this.props.currentRow + + this.props.count} `} + of {this.props.totalRows} rows + +
+ {this.state.isOpen ? ( + + ) : ( + + )} + + ); + } +} + +export default class DatabasesPlugin extends FlipperPlugin< + DatabasesPluginState, + Actions, +> { + databaseClient: DatabaseClient; + + state: DatabasesPluginState = { + selectedDatabase: 0, + selectedDatabaseTable: null, + pageRowNumber: 0, + databases: [], + viewMode: 'data', + error: null, + currentPage: null, + currentStructure: null, + currentSort: null, + }; + + reducers = [ + [ + 'UpdateDatabases', + ( + state: DatabasesPluginState, + results: UpdateDatabasesEvent, + ): DatabasesPluginState => { + const updates = results.databases; + const databases = updates; + const selectedDatabase = + state.selectedDatabase || Object.values(databases)[0] + ? // $FlowFixMe + Object.values(databases)[0].id + : 0; + const selectedTable = databases[selectedDatabase - 1].tables[0]; + const sameTableSelected = + selectedDatabase === state.selectedDatabase && + selectedTable === state.selectedDatabaseTable; + return { + ...state, + databases, + selectedDatabase: selectedDatabase, + selectedDatabaseTable: selectedTable, + pageRowNumber: 0, + currentPage: sameTableSelected ? state.currentPage : null, + currentStructure: null, + currentSort: sameTableSelected ? state.currentSort : null, + }; + }, + ], + [ + 'UpdateSelectedDatabase', + ( + state: DatabasesPluginState, + event: SelectDatabaseEvent, + ): DatabasesPluginState => { + return { + ...state, + selectedDatabase: event.database, + selectedDatabaseTable: + state.databases[event.database - 1].tables[0] || null, + pageRowNumber: 0, + currentPage: null, + currentStructure: null, + currentSort: null, + }; + }, + ], + [ + 'UpdateSelectedDatabaseTable', + ( + state: DatabasesPluginState, + event: SelectDatabaseTableEvent, + ): DatabasesPluginState => { + return { + ...state, + selectedDatabaseTable: event.table, + pageRowNumber: 0, + currentPage: null, + currentStructure: null, + currentSort: null, + }; + }, + ], + [ + 'UpdateViewMode', + ( + state: DatabasesPluginState, + event: UpdateViewModeEvent, + ): DatabasesPluginState => { + return { + ...state, + viewMode: event.viewMode, + }; + }, + ], + [ + 'UpdatePage', + ( + state: DatabasesPluginState, + event: UpdatePageEvent, + ): DatabasesPluginState => { + return { + ...state, + currentPage: { + rows: event.values.map((row: Array, index: number) => + transformRow(event.columns, row, index), + ), + ...event, + }, + }; + }, + ], + [ + 'UpdateStructure', + ( + state: DatabasesPluginState, + event: UpdateStructureEvent, + ): DatabasesPluginState => { + return { + ...state, + currentStructure: { + databaseId: event.databaseId, + table: event.table, + columns: event.columns, + rows: event.rows.map((row: Array, index: number) => + transformRow(event.columns, row, index), + ), + indexesColumns: event.indexesColumns, + indexesValues: event.indexesValues.map( + (row: Array, index: number) => + transformRow(event.columns, row, index), + ), + }, + }; + }, + ], + [ + 'NextPage', + ( + state: DatabasesPluginState, + event: UpdatePageEvent, + ): DatabasesPluginState => { + return { + ...state, + pageRowNumber: state.pageRowNumber + PAGE_SIZE, + currentPage: null, + }; + }, + ], + [ + 'PreviousPage', + ( + state: DatabasesPluginState, + event: UpdatePageEvent, + ): DatabasesPluginState => { + return { + ...state, + pageRowNumber: Math.max(state.pageRowNumber - PAGE_SIZE, 0), + currentPage: null, + }; + }, + ], + [ + 'GoToRow', + ( + state: DatabasesPluginState, + event: GoToRowEvent, + ): DatabasesPluginState => { + if (!state.currentPage) { + return state; + } + const destinationRow = + event.row < 0 + ? 0 + : event.row >= state.currentPage.total - PAGE_SIZE + ? Math.max(state.currentPage.total - PAGE_SIZE, 0) + : event.row; + return { + ...state, + pageRowNumber: destinationRow, + currentPage: null, + }; + }, + ], + [ + 'Refresh', + ( + state: DatabasesPluginState, + event: RefreshEvent, + ): DatabasesPluginState => { + return { + ...state, + currentPage: null, + }; + }, + ], + [ + 'UpdateViewMode', + ( + state: DatabasesPluginState, + event: UpdateViewModeEvent, + ): DatabasesPluginState => { + return { + ...state, + viewMode: event.viewMode, + }; + }, + ], + [ + 'SortByChanged', + (state: DatabasesPluginState, event: SortByChangedEvent) => { + return { + ...state, + currentSort: event.sortOrder, + pageRowNumber: 0, + currentPage: null, + }; + }, + ], + ].reduce((acc, val) => { + const name = val[0]; + const f = val[1]; + + acc[name] = (previousState, event) => { + const newState = f(previousState, event); + this.onStateChanged(previousState, newState); + return newState; + }; + return acc; + }, {}); + + onStateChanged( + previousState: DatabasesPluginState, + newState: DatabasesPluginState, + ) { + const databaseId = newState.selectedDatabase; + const table = newState.selectedDatabaseTable; + if ( + newState.viewMode === 'data' && + newState.currentPage === null && + databaseId && + table + ) { + this.databaseClient + .getTableData({ + count: PAGE_SIZE, + databaseId: newState.selectedDatabase, + order: newState.currentSort?.key, + reverse: (newState.currentSort?.direction || 'up') === 'down', + table: table, + start: newState.pageRowNumber, + }) + .then(data => { + console.log(data); + this.dispatchAction({ + type: 'UpdatePage', + databaseId: databaseId, + table: table, + columns: data.columns, + values: data.values, + start: data.start, + count: data.count, + total: data.total, + }); + }) + .catch(e => { + this.setState({error: e}); + }); + } + if ( + newState.viewMode === 'structure' && + newState.currentStructure === null && + databaseId && + table + ) { + this.databaseClient + .getTableStructure({ + databaseId: databaseId, + table: table, + }) + .then(data => { + console.log(data); + this.dispatchAction({ + type: 'UpdateStructure', + databaseId: databaseId, + table: table, + columns: data.structureColumns, + rows: data.structureValues, + indexesColumns: data.indexesColumns, + indexesValues: data.indexesValues, + }); + }) + .catch(e => { + this.setState({error: e}); + }); + } + } + + init() { + this.databaseClient = new DatabaseClient(this.client); + this.databaseClient.getDatabases({}).then(databases => { + console.log(databases); + this.dispatchAction({ + type: 'UpdateDatabases', + databases, + }); + }); + } + + onDataClicked = () => { + this.dispatchAction({type: 'UpdateViewMode', viewMode: 'data'}); + }; + + onStructureClicked = () => { + this.dispatchAction({type: 'UpdateViewMode', viewMode: 'structure'}); + }; + + onRefreshClicked = () => { + this.dispatchAction({type: 'Refresh'}); + }; + + onDatabaseSelected = (selected: string) => { + const dbId = this.state.databases.find(x => x.name === selected)?.id || 0; + this.dispatchAction({ + database: dbId, + type: 'UpdateSelectedDatabase', + }); + }; + + onDatabaseTableSelected = (selected: string) => { + this.dispatchAction({ + table: selected, + type: 'UpdateSelectedDatabaseTable', + }); + }; + + onNextPageClicked = () => { + this.dispatchAction({type: 'NextPage'}); + }; + + onPreviousPageClicked = () => { + this.dispatchAction({type: 'PreviousPage'}); + }; + + onGoToRow = (row: number, count: number) => { + this.dispatchAction({type: 'GoToRow', row: row}); + }; + + renderStructure() { + return [ + renderDatabaseColumns(this.state.currentStructure), + renderDatabaseIndexes(this.state.currentStructure), + ]; + } + + render() { + const tableOptions = + (this.state.selectedDatabase && + this.state.databases[this.state.selectedDatabase - 1] && + this.state.databases[this.state.selectedDatabase - 1].tables.reduce( + (options, tableName) => ({...options, [tableName]: tableName}), + {}, + )) || + {}; + + return ( + + + Database + +
+ + + + + {this.state.viewMode === 'data' + ? renderTable(this.state.currentPage, this) + : this.renderStructure()} + + + + + + + + {this.state.viewMode === 'data' && this.state.currentPage ? ( + + ) : null} + {this.state.viewMode === 'data' && this.state.currentPage ? ( + 0} + canGoForward={ + this.state.currentPage.start + this.state.currentPage.count < + this.state.currentPage.total + } + onBack={this.onPreviousPageClicked} + onForward={this.onNextPageClicked} + /> + ) : null} + + + {this.state.error && JSON.stringify(this.state.error)} + + ); + } +} diff --git a/src/plugins/databases/package.json b/src/plugins/databases/package.json new file mode 100644 index 000000000..06a4b238c --- /dev/null +++ b/src/plugins/databases/package.json @@ -0,0 +1,15 @@ +{ + "name": "Databases", + "version": "1.0.0", + "title": "Databases", + "icon": "internet", + "main": "index.js", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.11" + }, + "bugs": { + "email": "oncall+flipper@xmail.facebook.com", + "url": "https://fb.workplace.com/groups/230455004101832/" + } +} diff --git a/src/plugins/databases/yarn.lock b/src/plugins/databases/yarn.lock new file mode 100644 index 000000000..dafb3316c --- /dev/null +++ b/src/plugins/databases/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +lodash@^4.17.11: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== diff --git a/src/ui/components/Button.js b/src/ui/components/Button.js index 2d669b258..c98182ffc 100644 --- a/src/ui/components/Button.js +++ b/src/ui/components/Button.js @@ -127,15 +127,17 @@ const StyledButton = styled('div')(props => ({ marginLeft: 0, }, - '&:active': { - borderColor: colors.macOSTitleBarButtonBorder, - borderBottomColor: colors.macOSTitleBarButtonBorderBottom, - background: `linear-gradient(to bottom, ${ - colors.macOSTitleBarButtonBackgroundActiveHighlight - } 1px, ${colors.macOSTitleBarButtonBackgroundActive} 0%, ${ - colors.macOSTitleBarButtonBorderBlur - } 100%)`, - }, + '&:active': props.disabled + ? null + : { + borderColor: colors.macOSTitleBarButtonBorder, + borderBottomColor: colors.macOSTitleBarButtonBorderBottom, + background: `linear-gradient(to bottom, ${ + colors.macOSTitleBarButtonBackgroundActiveHighlight + } 1px, ${colors.macOSTitleBarButtonBackgroundActive} 0%, ${ + colors.macOSTitleBarButtonBorderBlur + } 100%)`, + }, '&:disabled': { borderColor: borderColor(props), diff --git a/src/ui/components/table/ManagedTable.js b/src/ui/components/table/ManagedTable.js index e896c0d60..d483c5bbc 100644 --- a/src/ui/components/table/ManagedTable.js +++ b/src/ui/components/table/ManagedTable.js @@ -116,6 +116,7 @@ export type ManagedTableProps = {| * Allows to create context menu items for rows. */ buildContextMenuItems?: () => MenuTemplate, + initialSortOrder?: ?TableRowSortOrder, /** * Callback when sorting changes. */ diff --git a/src/ui/components/table/TableHead.js b/src/ui/components/table/TableHead.js index aab7d7d8d..4c95aa18c 100644 --- a/src/ui/components/table/TableHead.js +++ b/src/ui/components/table/TableHead.js @@ -171,7 +171,8 @@ class TableHeadColumn extends PureComponent<{ + onResize={this.onResize} + minWidth={20}> {children} ); diff --git a/src/ui/components/table/TypeBasedValueRenderer.js b/src/ui/components/table/TypeBasedValueRenderer.js new file mode 100644 index 000000000..7679a07b4 --- /dev/null +++ b/src/ui/components/table/TypeBasedValueRenderer.js @@ -0,0 +1,68 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ +import {default as styled} from 'react-emotion'; +import {colors} from '../colors'; +import {default as Text} from '../Text'; + +export type Value = + | { + type: 'string', + value: string, + } + | { + type: 'boolean', + value: boolean, + } + | { + type: 'integer' | 'float' | 'double' | 'number', + value: number, + } + | { + type: 'null', + }; + +const NonWrappingText = styled(Text)({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + userSelect: 'none', +}); + +const BooleanValue = styled(NonWrappingText)(props => ({ + '&::before': { + content: '""', + display: 'inline-block', + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: props.active ? colors.green : colors.red, + marginRight: 5, + marginTop: 1, + }, +})); + +export function renderValue(val: Value) { + switch (val.type) { + case 'boolean': + return ( + + {val.value.toString()} + + ); + case 'string': + return {val.value}; + case 'integer': + case 'float': + case 'double': + case 'number': + return {val.value}; + case 'null': + return NULL; + default: + return {val.value}; + } +} diff --git a/src/ui/index.js b/src/ui/index.js index 8b3d65498..9750809d0 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -25,7 +25,7 @@ export {default as LoadingIndicator} from './components/LoadingIndicator.js'; // export {default as Popover} from './components/Popover.js'; -// +// tables export type { TableColumns, TableRows, @@ -39,6 +39,8 @@ export type { } from './components/table/types.js'; export {default as ManagedTable} from './components/table/ManagedTable.js'; export type {ManagedTableProps} from './components/table/ManagedTable.js'; +export type {Value} from './components/table/TypeBasedValueRenderer.js'; +export {renderValue} from './components/table/TypeBasedValueRenderer.js'; // export type {