├── .gitignore ├── settings.gradle.kts ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ ├── release.yml │ └── build.yml ├── lombok.config ├── src └── main │ ├── java │ ├── lombok.config │ └── dev │ │ └── niels │ │ └── sqlbackuprestore │ │ ├── ui │ │ ├── filedialog │ │ │ ├── DialogType.java │ │ │ ├── DatabaseFileSystem.java │ │ │ ├── Chooser.java │ │ │ ├── RemoteFile.java │ │ │ └── FileDialog.java │ │ ├── RestoreFilenamesDialog.java │ │ ├── RestoreFullPartialDialog.java │ │ ├── AppSettingsComponent.java │ │ └── SQLHelper.java │ │ ├── action │ │ ├── Group.java │ │ ├── Util.java │ │ ├── Backup.java │ │ ├── Download.java │ │ └── Restore.java │ │ ├── Constants.java │ │ ├── SettingsConfigurable.java │ │ ├── AppSettingsState.java │ │ └── query │ │ ├── ProgressTask.java │ │ ├── RemoteFileWithMeta.java │ │ ├── Query.java │ │ ├── QueryHelper.java │ │ ├── Auditor.java │ │ └── Client.java │ └── resources │ └── META-INF │ └── plugin.xml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── LICENSE ├── README.md ├── gradlew.bat ├── CHANGELOG.md ├── gradlew └── .editorconfig /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | .idea 3 | build 4 | /.intellijPlatform/ 5 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "SQLServerBackupAndRestore" 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: nvdweem 2 | custom: ["https://paypal.me/nvdweem"] 3 | -------------------------------------------------------------------------------- /lombok.config: -------------------------------------------------------------------------------- 1 | # This file is generated by the 'io.freefair.lombok' Gradle plugin 2 | config.stopBubbling = true 3 | -------------------------------------------------------------------------------- /src/main/java/lombok.config: -------------------------------------------------------------------------------- 1 | # This file is generated by the 'io.freefair.lombok' Gradle plugin 2 | lombok.accessors.chain = true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nvdweem/intellij-sqlserver-backup-restore/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/filedialog/DialogType.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui.filedialog; 2 | 3 | public enum DialogType { 4 | SAVE, LOAD 5 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug:** 10 | 11 | 12 | **Steps to reproduce:** 13 | 14 | 15 | **Expected behavior:** 16 | 17 | 18 | **Additional context:** 19 | 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration: 2 | # https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Maintain dependencies for Gradle dependencies 7 | - package-ecosystem: "gradle" 8 | directory: "/" 9 | target-branch: "master" 10 | schedule: 11 | interval: "daily" 12 | # Maintain dependencies for GitHub Actions 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | target-branch: "master" 16 | schedule: 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/action/Group.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.action; 2 | 3 | import com.intellij.openapi.actionSystem.ActionUpdateThread; 4 | import com.intellij.openapi.actionSystem.AnActionEvent; 5 | import com.intellij.openapi.actionSystem.DefaultActionGroup; 6 | import dev.niels.sqlbackuprestore.query.QueryHelper; 7 | import org.jetbrains.annotations.NotNull; 8 | 9 | public class Group extends DefaultActionGroup { 10 | @Override 11 | public @NotNull ActionUpdateThread getActionUpdateThread() { 12 | return ActionUpdateThread.BGT; 13 | } 14 | 15 | @Override 16 | public void update(@NotNull AnActionEvent e) { 17 | e.getPresentation().setEnabledAndVisible(QueryHelper.isMssql(e)); 18 | } 19 | 20 | @Override 21 | public boolean isDumbAware() { 22 | return true; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/action/Util.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.action; 2 | 3 | public class Util { 4 | private Util() { 5 | } 6 | 7 | public static String humanReadableByteCountSI(long bytes) { // NOSONAR 8 | String s = bytes < 0 ? "-" : ""; // NOSONAR 9 | long b = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes); // NOSONAR 10 | return b < 1000L ? bytes + " B" // NOSONAR 11 | : b < 999_950L ? String.format("%s%.1f kB", s, b / 1e3) // NOSONAR 12 | : (b /= 1000) < 999_950L ? String.format("%s%.1f MB", s, b / 1e3) // NOSONAR 13 | : (b /= 1000) < 999_950L ? String.format("%s%.1f GB", s, b / 1e3) // NOSONAR 14 | : (b /= 1000) < 999_950L ? String.format("%s%.1f TB", s, b / 1e3) // NOSONAR 15 | : (b /= 1000) < 999_950L ? String.format("%s%.1f PB", s, b / 1e3) // NOSONAR 16 | : String.format("%s%.1f EB", s, b / 1e6); // NOSONAR 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # IntelliJ Platform Artifacts Repositories 2 | # -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html 3 | 4 | pluginGroup = dev.niels 5 | pluginName = SQL Server Backup And Restore 6 | # SemVer format -> https://semver.org 7 | pluginVersion = 1.0.9 8 | 9 | # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html 10 | # for insight into build numbers and IntelliJ Platform versions. 11 | pluginSinceBuild = 241 12 | pluginUntilBuild = 13 | 14 | # IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties 15 | platformType = IU 16 | platformVersion = LATEST-EAP-SNAPSHOT 17 | 18 | # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html 19 | # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 20 | platformBundledPlugins = com.intellij.database 21 | 22 | # Java language level used to compile sources and to generate the files for - Java 11 is required since 2020.3 23 | javaVersion = 17 24 | 25 | # Gradle Releases -> https://github.com/gradle/gradle/releases 26 | gradleVersion = 8.5 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Niels van de Weem 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # intellij-sqlserver-backup-restore 2 | [Plugin on Jetbrains website](https://plugins.jetbrains.com/plugin/13913) 3 | 4 | A plugin that allows creating backups and restoring them from the DataGrip context for Microsoft SQLServer databases. 5 | 6 | The plugin built for my own personal use case which means that it will work for databases that are connected through an SSH tunnel. 7 | It supports downloading backups from the remote server to the local machine without using `xp_cmdshell` command. 8 | 9 | Features: 10 | - Creating a backup and storing it on the server 11 | - Creating a backup, storing it and download it right after 12 | - Reading a backup into an existing database 13 | - Reading a backup into a newly created database 14 | 15 | **Note:** AWS and probably also Azure don't seem to support file based backing up and are not supported by this plugin. 16 | 17 | 18 | # Docker mssql for Linux testing 19 | ``` 20 | /mnt/user/temp 21 | docker run -it --rm -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=mypass1!" -p 1433:1433 -v /mnt/user/temp:/opt/backups mcr.microsoft.com/mssql/server:2019-latest 22 | ``` -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/Constants.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore; 2 | 3 | import com.intellij.database.dataSource.connection.DatabaseDepartment; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | import javax.swing.Icon; 7 | 8 | public class Constants { 9 | public static final String NOTIFICATION_GROUP = "SQL Backup and Restore"; 10 | public static final String ERROR = "Error occurred"; 11 | 12 | private Constants() { 13 | } 14 | 15 | public static final DatabaseDepartment databaseDepartment = new DatabaseDepartment() { 16 | @NotNull @Override 17 | public String getDepartmentName() { 18 | return "MSSQL Department name"; 19 | } 20 | 21 | @NotNull @Override 22 | public String getCommonName() { 23 | return "MSSQL Common name"; 24 | } 25 | 26 | @Override 27 | public Icon getIcon() { 28 | return null; 29 | } 30 | 31 | @Override 32 | public boolean isInternal() { 33 | return false; 34 | } 35 | 36 | @Override 37 | public boolean isService() { 38 | return false; 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/SettingsConfigurable.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore; 2 | 3 | import com.intellij.openapi.options.Configurable; 4 | import dev.niels.sqlbackuprestore.ui.AppSettingsComponent; 5 | import org.jetbrains.annotations.Nullable; 6 | 7 | import javax.swing.JComponent; 8 | 9 | public class SettingsConfigurable implements Configurable { 10 | private AppSettingsComponent settingsComponent; 11 | 12 | @Override 13 | public String getDisplayName() { 14 | return "SQLServer Backup And Restore"; 15 | } 16 | 17 | @Nullable 18 | @Override 19 | public JComponent createComponent() { 20 | settingsComponent = new AppSettingsComponent(); 21 | return settingsComponent.getMainPanel(); 22 | } 23 | 24 | @Override 25 | public boolean isModified() { 26 | return settingsComponent.isModified(); 27 | } 28 | 29 | @Override 30 | public void apply() { 31 | settingsComponent.apply(); 32 | } 33 | 34 | @Override 35 | public void reset() { 36 | settingsComponent.reset(); 37 | } 38 | 39 | @Override 40 | public void disposeUIResources() { 41 | settingsComponent = null; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/AppSettingsState.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore; 2 | 3 | import com.intellij.openapi.application.ApplicationManager; 4 | import com.intellij.openapi.components.PersistentStateComponent; 5 | import com.intellij.openapi.components.State; 6 | import com.intellij.openapi.components.Storage; 7 | import com.intellij.util.xmlb.XmlSerializerUtil; 8 | import lombok.Data; 9 | import org.jetbrains.annotations.NotNull; 10 | import org.jetbrains.annotations.Nullable; 11 | 12 | @State( 13 | name = "dev.niels.sqlbackuprestore.AppSettingsState", 14 | storages = {@Storage("SQLBackupRestore.xml")} 15 | ) 16 | @Data 17 | public class AppSettingsState implements PersistentStateComponent { 18 | private long compressionSize = 0L; 19 | private boolean useCompressedBackup = true; 20 | private boolean useDbNameOnDownload = false; 21 | private boolean askForRestoreFileLocations = false; 22 | private boolean enableDownloadOption = false; 23 | 24 | public static AppSettingsState getInstance() { 25 | return ApplicationManager.getApplication().getService(AppSettingsState.class); 26 | } 27 | 28 | @Nullable 29 | @Override 30 | public AppSettingsState getState() { 31 | return this; 32 | } 33 | 34 | @Override 35 | public void loadState(@NotNull AppSettingsState state) { 36 | XmlSerializerUtil.copyBean(state, this); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | dev.niels.SQLServerBackupAndRestore 3 | SQL Server Backup And Restore 4 | Niels vd Weem 5 | 6 | com.intellij.modules.platform 7 | com.intellij.database 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/query/ProgressTask.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.query; 2 | 3 | import com.intellij.openapi.progress.ProgressIndicator; 4 | import com.intellij.openapi.progress.Task.Backgroundable; 5 | import com.intellij.openapi.project.Project; 6 | import dev.niels.sqlbackuprestore.query.Auditor.MessageType; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.jetbrains.annotations.Nls; 9 | import org.jetbrains.annotations.Nls.Capitalization; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.jetbrains.annotations.Nullable; 12 | 13 | import java.util.function.BiConsumer; 14 | import java.util.function.Consumer; 15 | import java.util.regex.Pattern; 16 | 17 | /** 18 | * Task that keeps track of progress for backup/restore actions. 19 | */ 20 | @Slf4j 21 | public class ProgressTask extends Backgroundable { 22 | private static final Pattern progressPattern = Pattern.compile("\\[3211] (\\d+)"); 23 | private final Consumer> run; 24 | private ProgressIndicator indicator; 25 | 26 | public ProgressTask(@Nullable Project project, @Nls(capitalization = Capitalization.Sentence) @NotNull String title, boolean canBeCancelled, Consumer> run) { 27 | super(project, title, canBeCancelled); 28 | this.run = run; 29 | } 30 | 31 | @Override 32 | public void run(ProgressIndicator indicator) { 33 | this.indicator = indicator; 34 | indicator.setText(getTitle()); 35 | indicator.setIndeterminate(false); 36 | indicator.setFraction(0.0); 37 | 38 | run.accept(this::consumeWarning); 39 | } 40 | 41 | private void consumeWarning(MessageType type, String warning) { 42 | if (type == MessageType.WARN && warning.contains("3211")) { 43 | var matcher = progressPattern.matcher(warning); 44 | if (matcher.find()) { 45 | indicator.setFraction(Integer.parseInt(matcher.group(1)) / 100d); 46 | } 47 | } else { 48 | log.warn("Warning: {}:{}", type, warning); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/query/RemoteFileWithMeta.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.query; 2 | 3 | import dev.niels.sqlbackuprestore.ui.filedialog.RemoteFile; 4 | import lombok.Data; 5 | 6 | import java.util.Objects; 7 | import java.util.function.Function; 8 | 9 | @Data 10 | public class RemoteFileWithMeta { 11 | private final RemoteFile file; 12 | private final BackupType type; 13 | private final long firstLSN; 14 | private final long databaseBackupLSN; 15 | private final String backupFinishDate; 16 | private final String machineName; 17 | 18 | public static Function factory(Client c) { 19 | return file -> new RemoteFileWithMeta(c, file); 20 | } 21 | 22 | public RemoteFileWithMeta(Client c, RemoteFile file) { 23 | var result = c.withRows("RESTORE HEADERONLY FROM DISK = N'" + file.getPath() + "' WITH NOUNLOAD;", (cs, rs) -> { 24 | }).join(); 25 | 26 | this.file = file; 27 | this.type = BackupType.from(toNumber(result.get(0).get("BackupType"), Number::intValue)); 28 | this.firstLSN = toNumber(result.get(0).get("FirstLSN"), Number::longValue); 29 | this.databaseBackupLSN = toNumber(result.get(0).get("DatabaseBackupLSN"), Number::longValue); 30 | this.backupFinishDate = Objects.toString(result.get(0).get("BackupFinishDate"), ""); 31 | this.machineName = Objects.toString(result.get(0).get("MachineName"), ""); 32 | } 33 | 34 | public boolean isFull() { 35 | return type == BackupType.FULL; 36 | } 37 | 38 | public boolean isPartialOf(RemoteFileWithMeta other) { 39 | return type == BackupType.PARTIAL && other.type == BackupType.FULL && other.firstLSN == databaseBackupLSN; 40 | } 41 | 42 | private T toNumber(Object o, Function getter) { 43 | return getter.apply(o instanceof Number nr ? nr : -1); 44 | } 45 | 46 | public enum BackupType { 47 | FULL, PARTIAL, UNSUPPORTED; 48 | 49 | public static BackupType from(int value) { 50 | return switch (value) { 51 | case 1 -> FULL; 52 | case 5 -> PARTIAL; 53 | default -> UNSUPPORTED; 54 | }; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/RestoreFilenamesDialog.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui; 2 | 3 | import com.intellij.openapi.project.Project; 4 | import com.intellij.openapi.ui.DialogWrapper; 5 | import com.intellij.ui.components.JBScrollPane; 6 | import com.intellij.ui.table.JBTable; 7 | import dev.niels.sqlbackuprestore.action.Restore.RestoreTemp; 8 | import one.util.streamex.StreamEx; 9 | import org.apache.commons.lang3.StringUtils; 10 | import org.jetbrains.annotations.Nullable; 11 | 12 | import javax.swing.JComponent; 13 | import javax.swing.table.DefaultTableModel; 14 | 15 | public class RestoreFilenamesDialog extends DialogWrapper { 16 | private final RestoreTemp temp; 17 | 18 | public RestoreFilenamesDialog(@Nullable Project project, RestoreTemp temp) { 19 | super(project); 20 | this.temp = temp; 21 | 22 | init(); 23 | setTitle("Files"); 24 | } 25 | 26 | @Override 27 | protected @Nullable JComponent createCenterPanel() { 28 | var model = new DefaultTableModel(new String[]{"Logical file name", "File type", "Original file name", "Restore as"}, 0) { 29 | @Override public boolean isCellEditable(int row, int column) { 30 | return column == 3; 31 | } 32 | 33 | @Override public void setValueAt(Object aValue, int row, int column) { 34 | super.setValueAt(aValue, row, column); 35 | temp.getFiles().get(row).put("RestoreAs", asString(aValue)); 36 | } 37 | }; 38 | StreamEx.of(temp.getFiles()).map(f -> new String[]{ 39 | StringUtils.defaultString(asString(f.get("LogicalName"))), 40 | StringUtils.defaultString(asString(f.get("Type"))), 41 | StringUtils.defaultString(asString(f.get("PhysicalName"))), 42 | StringUtils.defaultString(asString(f.get("RestoreAs")))}) 43 | .forEach(model::addRow); 44 | var table = new JBTable(model); 45 | table.getColumnModel().getColumn(0).setPreferredWidth(120); 46 | table.getColumnModel().getColumn(1).setPreferredWidth(60); 47 | table.getColumnModel().getColumn(2).setPreferredWidth(260); 48 | table.getColumnModel().getColumn(3).setPreferredWidth(260); 49 | 50 | return new JBScrollPane(table); 51 | } 52 | 53 | private String asString(Object o) { 54 | return o == null ? "" : o.toString(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/query/Query.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.query; 2 | 3 | import com.intellij.database.datagrid.DataRequest; 4 | import com.intellij.database.datagrid.DataRequest.RawQueryRequest; 5 | import com.intellij.database.datagrid.GridColumn; 6 | import com.intellij.database.datagrid.GridDataRequest; 7 | import com.intellij.database.datagrid.GridRow; 8 | import lombok.Getter; 9 | import lombok.extern.slf4j.Slf4j; 10 | import one.util.streamex.StreamEx; 11 | import org.jetbrains.annotations.NotNull; 12 | 13 | import java.util.ArrayList; 14 | import java.util.HashMap; 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.concurrent.CompletableFuture; 18 | import java.util.function.BiConsumer; 19 | 20 | @Slf4j 21 | public class Query extends RawQueryRequest { 22 | private final BiConsumer, List> consumer; 23 | private final List> result = new ArrayList<>(); 24 | private List columns; 25 | @Getter 26 | private final CompletableFuture>> future = new CompletableFuture<>(); 27 | 28 | protected Query(Client c, Owner owner, String query, BiConsumer, List> consumer) { 29 | super(owner, query, DataRequest.newConstraints(0, 5000, 0, 0, 0)); 30 | this.consumer = consumer; 31 | 32 | c.open(); 33 | getPromise().onProcessed(x -> { 34 | future.complete(result); 35 | c.close(); 36 | }); 37 | } 38 | 39 | @Override public void updateColumns(@NotNull GridDataRequest.Context context, GridColumn @NotNull [] columns) { 40 | this.columns = List.of(columns); 41 | } 42 | 43 | @Override public void addRows(@NotNull GridDataRequest.Context context, @NotNull List list) { 44 | if (columns == null) { 45 | log.error("No columns set yet, ignoring rows"); 46 | return; 47 | } 48 | 49 | if (consumer != null) { 50 | consumer.accept(columns, StreamEx.of(list).select(GridRow.class).toImmutableList()); 51 | } 52 | result.addAll(list.stream().map(r -> columns.stream().collect(HashMap::new, (m, v) -> m.put(v.getName(), v.getValue(r)), HashMap::putAll)).toList()); 53 | } 54 | 55 | @Override public void afterLastRowAdded(@NotNull GridDataRequest.Context context, int total) { 56 | super.afterLastRowAdded(context, total); 57 | future.complete(result); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/query/QueryHelper.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.query; 2 | 3 | import com.intellij.database.dialects.mssql.model.MsDatabase; 4 | import com.intellij.database.psi.DbDataSource; 5 | import com.intellij.database.psi.DbElement; 6 | import com.intellij.database.psi.DbNamespaceImpl; 7 | import com.intellij.database.util.DbImplUtil; 8 | import com.intellij.openapi.actionSystem.AnActionEvent; 9 | import com.intellij.openapi.actionSystem.CommonDataKeys; 10 | import com.intellij.psi.PsiElement; 11 | import org.jetbrains.annotations.NotNull; 12 | import org.jetbrains.annotations.Nullable; 13 | 14 | import java.util.ArrayList; 15 | import java.util.List; 16 | import java.util.Optional; 17 | 18 | import static com.intellij.openapi.actionSystem.PlatformCoreDataKeys.PSI_ELEMENT_ARRAY; 19 | 20 | /** 21 | * Helper to go from actions to table names or connections. 22 | */ 23 | public abstract class QueryHelper { 24 | private static final List clients = new ArrayList<>(); 25 | 26 | private QueryHelper() { 27 | } 28 | 29 | public static boolean isMssql(@NotNull AnActionEvent e) { 30 | return QueryHelper.getSource(e).map(ds -> ds.getDbms().isMicrosoft()).orElse(false); 31 | } 32 | 33 | private static Optional getNamespace(@NotNull AnActionEvent e) { 34 | var element = getPsiElement(e); 35 | while (element != null && (!(element instanceof DbNamespaceImpl) || !(((DbElement) element).getDelegate() instanceof MsDatabase))) { 36 | element = element.getParent(); 37 | } 38 | return Optional.ofNullable(element == null ? null : (DbNamespaceImpl) element); 39 | } 40 | 41 | private static Optional getSource(@NotNull AnActionEvent e) { 42 | var element = getPsiElement(e); 43 | while (element != null && !(element instanceof DbDataSource)) { 44 | element = element.getParent(); 45 | } 46 | return Optional.ofNullable(element == null ? null : (DbDataSource) element); 47 | } 48 | 49 | private static @Nullable PsiElement getPsiElement(@NotNull AnActionEvent e) { 50 | var element = e.getData(CommonDataKeys.PSI_ELEMENT); 51 | if (element == null) { 52 | var arr = e.getData(PSI_ELEMENT_ARRAY); 53 | if (arr != null && arr.length > 0) { 54 | element = arr[0]; 55 | } 56 | } 57 | return element; 58 | } 59 | 60 | public static Optional getDatabase(@NotNull AnActionEvent e) { 61 | return getNamespace(e).map(d -> (MsDatabase) d.getDelegate()); 62 | } 63 | 64 | public static Client client(@NotNull AnActionEvent e) { 65 | cleanOldClients(); 66 | var client = new Client(e.getProject(), getSource(e).map(DbImplUtil::getMaybeLocalDataSource).orElseThrow()); 67 | clients.add(client); 68 | return client; 69 | } 70 | 71 | public static void cleanOldClients() { 72 | clients.forEach(Client::cleanIfDone); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/query/Auditor.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.query; 2 | 3 | import com.intellij.database.connection.throwable.info.ErrorInfo; 4 | import com.intellij.database.connection.throwable.info.WarningInfo; 5 | import com.intellij.database.datagrid.DataAuditor; 6 | import com.intellij.database.datagrid.DataProducer; 7 | import com.intellij.database.datagrid.DataRequest; 8 | import com.intellij.database.datagrid.DataRequest.Context; 9 | import org.jetbrains.annotations.NotNull; 10 | import org.jetbrains.annotations.Nullable; 11 | 12 | import java.util.HashSet; 13 | import java.util.Set; 14 | import java.util.function.BiConsumer; 15 | 16 | public class Auditor implements DataAuditor { 17 | private final Set> consumers = new HashSet<>(); 18 | 19 | public enum MessageType { 20 | PRINT, WARN, ERROR 21 | } 22 | 23 | public void addWarningConsumer(BiConsumer consumer) { 24 | consumers.add(consumer); 25 | } 26 | 27 | private void produce(MessageType type, String s) { 28 | if (!consumers.isEmpty()) { 29 | consumers.forEach(c -> c.accept(type, s)); 30 | } 31 | } 32 | 33 | @Override 34 | public void print(@NotNull Context context, @Nullable String s) { 35 | produce(MessageType.PRINT, s); 36 | } 37 | 38 | @Override public void warn(@NotNull Context context, @NotNull WarningInfo warningInfo) { 39 | produce(MessageType.WARN, warningInfo.getMessage()); 40 | } 41 | 42 | @Override public void error(@NotNull Context context, @NotNull ErrorInfo errorInfo) { 43 | produce(MessageType.ERROR, errorInfo.getMessage()); 44 | } 45 | 46 | @Override 47 | public void beforeStatement(@NotNull Context context) { 48 | // Not needed 49 | } 50 | 51 | @Override 52 | public void afterStatement(@NotNull Context context) { 53 | // Not needed 54 | } 55 | 56 | @Override 57 | public void updateCountReceived(@NotNull Context context, int i) { 58 | // Not needed 59 | } 60 | 61 | @Override 62 | public void fetchStarted(@NotNull Context context, int i) { 63 | // Not needed 64 | } 65 | 66 | @Override 67 | public void fetchFinished(@NotNull Context context, int i, int i1) { 68 | // Not needed 69 | } 70 | 71 | @Override 72 | public void requestStarted(@NotNull Context context) { 73 | // Not needed 74 | } 75 | 76 | @Override 77 | public void requestFinished(@NotNull Context context) { 78 | // Not needed 79 | } 80 | 81 | @Override 82 | public void txCompleted(@NotNull Context context, @NotNull TxEvent txEvent) { 83 | // Not needed 84 | } 85 | 86 | @Override 87 | public void jobSubmitted(@NotNull DataRequest dataRequest, @NotNull DataProducer dataProducer) { 88 | // Not needed 89 | } 90 | 91 | @Override 92 | public void jobFinished(@NotNull DataRequest dataRequest, @NotNull DataProducer dataProducer) { 93 | // Not needed 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for handling the release process based on the draft release prepared 2 | # with the Build workflow. Running the publishPlugin task requires the INTELLIJ_TOKEN secret provided. 3 | 4 | name: Release 5 | on: 6 | release: 7 | types: [prereleased, released] 8 | 9 | jobs: 10 | 11 | # Prepare and publish the plugin to the Marketplace repository 12 | release: 13 | name: Publish Plugin 14 | runs-on: ubuntu-latest 15 | steps: 16 | 17 | # Check out current repository 18 | - name: Fetch Sources 19 | uses: actions/checkout@v5 20 | with: 21 | ref: ${{ github.event.release.tag_name }} 22 | 23 | # Setup Java 17 environment for the next steps 24 | - name: Setup Java 25 | uses: actions/setup-java@v5 26 | with: 27 | distribution: zulu 28 | java-version: 17 29 | cache: gradle 30 | 31 | # Set environment variables 32 | - name: Export Properties 33 | id: properties 34 | shell: bash 35 | run: | 36 | CHANGELOG="$(cat << 'EOM' | sed -e 's/^[[:space:]]*$//g' -e '/./,$!d' 37 | ${{ github.event.release.body }} 38 | EOM 39 | )" 40 | 41 | echo "changelog<> $GITHUB_OUTPUT 42 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 43 | echo "EOF" >> $GITHUB_OUTPUT 44 | 45 | # Update Unreleased section with the current release note 46 | - name: Patch Changelog 47 | if: ${{ steps.properties.outputs.changelog != '' }} 48 | env: 49 | CHANGELOG: ${{ steps.properties.outputs.changelog }} 50 | run: | 51 | ./gradlew patchChangelog --release-note="$CHANGELOG" 52 | 53 | # Publish the plugin to the Marketplace 54 | - name: Publish Plugin 55 | env: 56 | INTELLIJ_TOKEN: ${{ secrets.INTELLIJ_TOKEN }} 57 | CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} 58 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 59 | PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} 60 | run: ./gradlew publishPlugin 61 | 62 | # Upload artifact as a release asset 63 | - name: Upload Release Asset 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* 67 | 68 | # Create pull request 69 | - name: Create Pull Request 70 | if: ${{ steps.properties.outputs.changelog != '' }} 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | run: | 74 | VERSION="${{ github.event.release.tag_name }}" 75 | BRANCH="changelog-update-$VERSION" 76 | 77 | git config user.email "action@github.com" 78 | git config user.name "GitHub Action" 79 | 80 | git checkout -b $BRANCH 81 | git commit -am "Changelog update - $VERSION" 82 | git push --set-upstream origin $BRANCH 83 | 84 | gh pr create \ 85 | --title "Changelog update - \`$VERSION\`" \ 86 | --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ 87 | --base master \ 88 | --head $BRANCH 89 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 34 | 35 | @rem Find java.exe 36 | if defined JAVA_HOME goto findJavaFromJavaHome 37 | 38 | set JAVA_EXE=java.exe 39 | %JAVA_EXE% -version >NUL 2>&1 40 | if "%ERRORLEVEL%" == "0" goto init 41 | 42 | echo. 43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 44 | echo. 45 | echo Please set the JAVA_HOME variable in your environment to match the 46 | echo location of your Java installation. 47 | 48 | goto fail 49 | 50 | :findJavaFromJavaHome 51 | set JAVA_HOME=%JAVA_HOME:"=% 52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 53 | 54 | if exist "%JAVA_EXE%" goto init 55 | 56 | echo. 57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 58 | echo. 59 | echo Please set the JAVA_HOME variable in your environment to match the 60 | echo location of your Java installation. 61 | 62 | goto fail 63 | 64 | :init 65 | @rem Get command-line arguments, handling Windows variants 66 | 67 | if not "%OS%" == "Windows_NT" goto win9xME_args 68 | 69 | :win9xME_args 70 | @rem Slurp the command line arguments. 71 | set CMD_LINE_ARGS= 72 | set _SKIP=2 73 | 74 | :win9xME_args_slurp 75 | if "x%~1" == "x" goto execute 76 | 77 | set CMD_LINE_ARGS=%* 78 | 79 | :execute 80 | @rem Setup the command line 81 | 82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 83 | 84 | @rem Execute Gradle 85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 86 | 87 | :end 88 | @rem End local scope for the variables with windows NT shell 89 | if "%ERRORLEVEL%"=="0" goto mainEnd 90 | 91 | :fail 92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 93 | rem the _cmd.exe /c_ return code! 94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 95 | exit /b 1 96 | 97 | :mainEnd 98 | if "%OS%"=="Windows_NT" endlocal 99 | 100 | :omega 101 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/filedialog/DatabaseFileSystem.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui.filedialog; 2 | 3 | import com.intellij.openapi.vfs.NonPhysicalFileSystem; 4 | import com.intellij.openapi.vfs.VirtualFile; 5 | import com.intellij.openapi.vfs.VirtualFileListener; 6 | import com.intellij.openapi.vfs.VirtualFileSystem; 7 | import dev.niels.sqlbackuprestore.query.Client; 8 | import dev.niels.sqlbackuprestore.ui.SQLHelper; 9 | import lombok.Getter; 10 | import lombok.NoArgsConstructor; 11 | import lombok.RequiredArgsConstructor; 12 | import lombok.SneakyThrows; 13 | import org.jetbrains.annotations.NotNull; 14 | import org.jetbrains.annotations.Nullable; 15 | 16 | /** 17 | * Lists files from the connection 18 | */ 19 | @Getter 20 | @NoArgsConstructor(force = true) 21 | @RequiredArgsConstructor 22 | public class DatabaseFileSystem extends VirtualFileSystem implements NonPhysicalFileSystem { 23 | private static final String PROTOCOL = "mssqldb"; 24 | private final Client connection; 25 | 26 | @SneakyThrows 27 | public VirtualFile[] getRoots() { 28 | return SQLHelper.getDrives(connection).stream().map(r -> (String) r.get("Name")).map(p -> new RemoteFile(this, null, p, true, true)).toArray(RemoteFile[]::new); 29 | } 30 | 31 | @NotNull 32 | @Override 33 | public String getProtocol() { 34 | return PROTOCOL; 35 | } 36 | 37 | @Nullable 38 | @Override 39 | public VirtualFile findFileByPath(@NotNull String path) { 40 | return new RemoteFile(this, null, path, false, true); 41 | } 42 | 43 | @Override 44 | public void refresh(boolean asynchronous) { 45 | // Refreshing not needed 46 | } 47 | 48 | @Nullable 49 | @Override 50 | public VirtualFile refreshAndFindFileByPath(@NotNull String path) { 51 | return null; 52 | } 53 | 54 | @Override 55 | public void addVirtualFileListener(@NotNull VirtualFileListener listener) { 56 | // Not needed 57 | } 58 | 59 | @Override 60 | public void removeVirtualFileListener(@NotNull VirtualFileListener listener) { 61 | // Not needed 62 | } 63 | 64 | @Override 65 | protected void deleteFile(Object requestor, @NotNull VirtualFile vFile) { 66 | // Not needed 67 | } 68 | 69 | @Override 70 | protected void moveFile(Object requestor, @NotNull VirtualFile vFile, @NotNull VirtualFile newParent) { 71 | // Not needed 72 | } 73 | 74 | @Override 75 | protected void renameFile(Object requestor, @NotNull VirtualFile vFile, @NotNull String newName) { 76 | // Not needed 77 | } 78 | 79 | @NotNull @Override 80 | protected VirtualFile createChildFile(Object requestor, @NotNull VirtualFile vDir, @NotNull String fileName) { 81 | throw new IllegalStateException("Creating files is not supported"); 82 | } 83 | 84 | @NotNull @Override 85 | protected VirtualFile createChildDirectory(Object requestor, @NotNull VirtualFile vDir, @NotNull String dirName) { 86 | throw new IllegalStateException("Creating directories is not supported"); 87 | } 88 | 89 | @NotNull @Override 90 | protected VirtualFile copyFile(Object requestor, @NotNull VirtualFile virtualFile, @NotNull VirtualFile newParent, @NotNull String copyName) { 91 | throw new IllegalStateException("Copying files is not supported"); 92 | } 93 | 94 | @Override 95 | public boolean isReadOnly() { 96 | return false; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/RestoreFullPartialDialog.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui; 2 | 3 | import com.intellij.openapi.application.ApplicationManager; 4 | import com.intellij.openapi.project.Project; 5 | import com.intellij.openapi.ui.DialogWrapper; 6 | import com.intellij.ui.components.JBScrollPane; 7 | import com.intellij.ui.table.JBTable; 8 | import dev.niels.sqlbackuprestore.action.Restore.RestoreAction; 9 | import dev.niels.sqlbackuprestore.query.RemoteFileWithMeta; 10 | import lombok.Getter; 11 | import org.jetbrains.annotations.Nullable; 12 | 13 | import javax.swing.*; 14 | import javax.swing.table.DefaultTableModel; 15 | import java.util.ArrayList; 16 | import java.util.List; 17 | import java.util.Map; 18 | 19 | public class RestoreFullPartialDialog extends DialogWrapper { 20 | private final Map> fullsWithPartials; 21 | @Getter private RestoreAction result; 22 | 23 | private RestoreFullPartialDialog(@Nullable Project project, Map> fullsWithPartials) { 24 | super(project); 25 | this.fullsWithPartials = fullsWithPartials; 26 | 27 | init(); 28 | setTitle("Select Backup"); 29 | setOKActionEnabled(false); 30 | } 31 | 32 | public static RestoreAction choose(@Nullable Project project, Map> fullsWithPartials) { 33 | RestoreFullPartialDialog[] dialog = new RestoreFullPartialDialog[1]; 34 | ApplicationManager.getApplication().invokeAndWait(() -> { 35 | dialog[0] = new RestoreFullPartialDialog(project, fullsWithPartials); 36 | dialog[0].showAndGet(); 37 | }); 38 | return dialog[0].result; 39 | } 40 | 41 | private String[] fileToStringArr(RestoreAction action) { 42 | var sub = action.partialBackup() != null; 43 | var file = sub ? action.partialBackup() : action.fullBackup(); 44 | return new String[]{ 45 | (sub ? "- " : "") + file.getFile().getPath(), 46 | file.getType().toString(), 47 | file.getBackupFinishDate(), 48 | file.getMachineName() 49 | }; 50 | } 51 | 52 | @Override 53 | protected @Nullable JComponent createCenterPanel() { 54 | var model = new DefaultTableModel(new String[]{"File", "Backup type", "Backup date", "Machine name"}, 0) { 55 | @Override public boolean isCellEditable(int row, int column) { 56 | return false; 57 | } 58 | }; 59 | 60 | var actions = new ArrayList(); 61 | fullsWithPartials.forEach((key, value) -> { 62 | actions.add(new RestoreAction(key, null)); 63 | value.forEach(f -> actions.add(new RestoreAction(key, f))); 64 | }); 65 | actions.forEach(a -> model.addRow(fileToStringArr(a))); 66 | 67 | var table = new JBTable(model); 68 | table.getColumnModel().getColumn(0).setPreferredWidth(240); 69 | table.getColumnModel().getColumn(1).setPreferredWidth(100); 70 | table.getColumnModel().getColumn(2).setPreferredWidth(150); 71 | table.getColumnModel().getColumn(3).setPreferredWidth(75); 72 | table.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION); 73 | 74 | table.getSelectionModel().addListSelectionListener(i -> { 75 | result = actions.get(i.getFirstIndex()); 76 | setOKActionEnabled(true); 77 | }); 78 | 79 | return new JBScrollPane(table); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/filedialog/Chooser.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui.filedialog; 2 | 3 | import com.intellij.ide.util.PropertiesComponent; 4 | import com.intellij.openapi.fileChooser.FileSaverDescriptor; 5 | import com.intellij.openapi.fileChooser.ex.FileSaverDialogImpl; 6 | import com.intellij.openapi.project.Project; 7 | import com.intellij.openapi.ui.Messages; 8 | import com.intellij.openapi.vfs.VirtualFile; 9 | import com.intellij.ui.UIBundle; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.jetbrains.annotations.Nullable; 12 | 13 | import static dev.niels.sqlbackuprestore.ui.filedialog.FileDialog.getSelectionKeyName; 14 | 15 | /** 16 | * The regular FileSaverDialogImpl seems to lean a bit too much on regular files and not remote files. 17 | */ 18 | class Chooser extends FileSaverDialogImpl { 19 | private final Project project; 20 | private RemoteFile chosen; 21 | 22 | public Chooser(@NotNull FileSaverDescriptor descriptor, @Nullable Project project) { 23 | super(descriptor, project); 24 | this.project = project; 25 | } 26 | 27 | @Override 28 | public void setOKActionEnabled(boolean isEnabled) { 29 | var selected = getSelectedFile(); 30 | getOKAction().setEnabled(selected != null && !selected.isDirectory()); 31 | } 32 | 33 | /** 34 | * Retrieve the selected file as a RemoteFile. 35 | */ 36 | private RemoteFile getSelectedFile() { 37 | RemoteFile selected = (RemoteFile) myFileSystemTree.getSelectedFile(); 38 | if (selected == null) { 39 | return null; 40 | } 41 | 42 | var fileName = myFileName.getText(); 43 | if (!selected.getPath().endsWith(fileName)) { 44 | return (RemoteFile) selected.getChild(fileName, true); 45 | } 46 | return selected; 47 | } 48 | 49 | /** 50 | * Doesn't call the parent doOkAction because that one tries to find the selected file locally. 51 | */ 52 | @Override 53 | protected void doOKAction() { 54 | var file = getSelectedFile(); 55 | 56 | if (file != null && file.isExists() && Messages.YES != Messages.showYesNoDialog(getRootPane(), 57 | UIBundle.message("file.chooser.save.dialog.confirmation", file.getName()), 58 | UIBundle.message("file.chooser.save.dialog.confirmation.title"), 59 | Messages.getWarningIcon())) { 60 | return; 61 | } 62 | 63 | chosen = file; 64 | saveSelection(chosen); 65 | close(OK_EXIT_CODE); 66 | } 67 | 68 | private void saveSelection(RemoteFile file) { 69 | if (file != null) { 70 | if (!file.isDirectory()) { 71 | file = (RemoteFile) file.getParent(); 72 | } 73 | PropertiesComponent.getInstance(project).setValue(getSelectionKeyName(file.getConnection()), file.getPath()); 74 | } 75 | } 76 | 77 | public RemoteFile choose(RemoteFile initial, String fileName) { 78 | save(initial, fileName); 79 | return chosen; 80 | } 81 | 82 | @Override 83 | protected void restoreSelection(@Nullable VirtualFile toSelect) { 84 | if (toSelect == null) { 85 | return; 86 | } 87 | restoreSelection(toSelect, () -> myFileSystemTree.expand(toSelect, null)); 88 | } 89 | 90 | /** 91 | * We must be doing something wrong but this is apparently needed, just selecting the file we want to select 92 | * isn't enough. We need to open the tree one by one :( 93 | */ 94 | private void restoreSelection(@Nullable VirtualFile toSelect, Runnable andThen) { 95 | if (toSelect == null) { 96 | if (andThen != null) { 97 | andThen.run(); 98 | } 99 | } else { 100 | restoreSelection(toSelect.getParent(), () -> myFileSystemTree.select(toSelect, andThen)); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # SQLServer Backup and Restore Changelog 2 | 3 | ## Unreleased 4 | 5 | ## 1.0.9 - 2025-04-13 6 | 7 | - #57 - Fixed `update without where` clause warning when using download option 8 | 9 | ## 1.0.8 - 2024-11-20 10 | 11 | - Fixed npe when restoring while there is no initial folder known 12 | - (build) Switched to the new build plugin 13 | - (build) Hide a call to an @OverrideOnly method 14 | 15 | ## 1.0.7 - 2024-11-18 16 | 17 | - Added support for IntelliJ 2024.3 18 | 19 | ## 1.0.6 20 | 21 | - Add support for differential backups (pick both files when restoring) 22 | 23 | ## 1.0.5 24 | 25 | - Idea 2024.1 compatible 26 | 27 | ## 1.0.4 28 | 29 | - `Backup & Download` feature is now optional 30 | - File dialogs now show that the files might not be local 31 | - Fix usage of deprecated API 32 | - Increase since-version 33 | 34 | ## 1.0.3 35 | 36 | - Fix ArrayIndexOutOfBoundsException for Linux/macOS when restoring 37 | 38 | ## 1.0.2 39 | 40 | - Started using the IntelliJ Plugin Template 41 | - Plugin is now signed 42 | 43 | ## 1.0.1 44 | 45 | ### Fixed 46 | 47 | - Added support for IntelliJ 2022.2 48 | 49 | ## 1.0.0 50 | 51 | ### Added 52 | 53 | - Plugin can be used while IntelliJ is indexing 54 | 55 | ### Fixed 56 | 57 | - Downloading the backup doesn't trigger the unsafe query error anymore 58 | 59 | ## 0.8.1 60 | 61 | ### Fixed 62 | 63 | - SQLServer backup compression won't be done for Express, 'Express with Advanced Services' and Web editions even if it's turned on because they don't support it 64 | 65 | ## 0.8.0 66 | 67 | ### Added 68 | 69 | - gzipped files can be restored without needing to manually unzip them (Pull request from felhag) 70 | - When downloading, the question to compress is asked before loading the backup into the database. This helps with backups that are bigger than 2gb which is the default maximum size for blobs (Pull request from felhag) 71 | - Added setting to do compressed backups. The previous compression options are still available but shouldn't add much anymore 72 | - Allow changing filenames when restoring a backup 73 | 74 | ### Fixed 75 | 76 | - Listing files and drives is done similarly to what SSMS seems to do which means you shouldn't need sysadmin rights anymore 77 | - Added some fixes to make backing up and restoring work for SQLServer running in a docker container 78 | 79 | ## 0.7.0 80 | 81 | ### Changed 82 | 83 | - The suggested name for downloading a backup is the name the backup was given when backing up (Pull request from felhag) 84 | - Asking for compression and using database name as default filename is configurable 85 | 86 | ### Fixed 87 | 88 | - When cancelling a backup & download the connection with the database will be closed 89 | 90 | ## 0.6.1 91 | 92 | ### Fixed 93 | 94 | - Fix compatibility with v201.7223.18. 95 | - When the user can't list drives (EXEC master..xp_fixeddrives) a message is shown instead of a local file browser. 96 | 97 | ## 0.6.0 98 | 99 | ### Added 100 | 101 | - Progress indication when downloading backup 102 | - Allow closing connections when restoring 103 | 104 | ## 0.5.1 105 | 106 | ### Fixed 107 | 108 | - Fix NPE when opening a file dialog 109 | 110 | ## 0.5.0 111 | 112 | ### Changed 113 | 114 | - Show error when restore action fails 115 | - Progress for backup up and restoring is shown again 116 | - Store the last selected path and (try to) use it when backing up and restoring later 117 | - Fill in the backup filename when backing up and downloading 118 | 119 | ## 0.4.0 120 | 121 | ### Fixed 122 | 123 | - The filepicker asked for overwriting the file when a file was being selected for restoring. That doesn't happen anymore. 124 | 125 | ## 0.3.0 126 | 127 | ### Fixed 128 | 129 | - The filepicker for the backup action didn't always select the file, it sometimes picked the folder 130 | - When overwriting an existing file in the backup action it didn't prompt to overwrite the file 131 | - When a backup action fails a message is shown 132 | - An internal API was used, it isn't anymore 133 | 134 | ## 0.2.0 135 | 136 | ### Added 137 | 138 | - Downloading using jtds or ms driver 139 | - Refresh database after restore action 140 | 141 | ### Fixed 142 | 143 | - No infinite wait anymore when reading data 144 | - Sometimes the context menu items stayed disabled 145 | 146 | ## 0.1.0 147 | 148 | ### Added 149 | 150 | - Initial version. Seems to work fine locally :). 151 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/query/Client.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.query; 2 | 3 | import com.intellij.database.console.client.DatabaseSessionClient; 4 | import com.intellij.database.console.session.DatabaseSessionManager; 5 | import com.intellij.database.dataSource.LocalDataSource; 6 | import com.intellij.database.datagrid.DataRequest.Disconnect; 7 | import com.intellij.database.datagrid.GridColumn; 8 | import com.intellij.database.datagrid.GridRow; 9 | import com.intellij.openapi.project.Project; 10 | import com.intellij.openapi.util.Disposer; 11 | import dev.niels.sqlbackuprestore.Constants; 12 | import dev.niels.sqlbackuprestore.query.Auditor.MessageType; 13 | import lombok.Getter; 14 | 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.concurrent.CompletableFuture; 18 | import java.util.function.BiConsumer; 19 | 20 | public class Client implements AutoCloseable { 21 | private final DatabaseSessionClient dbClient; 22 | private final Auditor auditor; 23 | @Getter 24 | private final String dbName; 25 | private int useCount = 1; 26 | 27 | public Client(Project project, LocalDataSource dataSource) { 28 | dbClient = DatabaseSessionManager.getFacade(project, dataSource, null, null, null, Constants.databaseDepartment).client(); 29 | dbName = dataSource.getName(); 30 | auditor = new Auditor(); 31 | dbClient.getMessageBus().addAuditor(auditor); 32 | } 33 | 34 | public void setTitle(String title) { 35 | dbClient.getSession().setTitle(title); 36 | } 37 | 38 | public Client addWarningConsumer(BiConsumer consumer) { 39 | auditor.addWarningConsumer(consumer); 40 | return this; 41 | } 42 | 43 | private CompletableFuture>> getResult(String query, BiConsumer, List> consumer) { 44 | var table = new Query(this, dbClient, query, consumer); 45 | dbClient.getMessageBus().getDataProducer().processRequest(table); 46 | return table.getFuture(); 47 | } 48 | 49 | public CompletableFuture>> getResult(String query) { 50 | return getResult(query, null); 51 | } 52 | 53 | @SuppressWarnings("unchecked") 54 | public CompletableFuture getSingle(String query, String column) { 55 | return getResult(query).thenApply(r -> { 56 | if (r.isEmpty()) { 57 | throw new IllegalStateException("Expected at least one result for " + query); 58 | } 59 | return (T) r.get(0).get(column); 60 | }); 61 | } 62 | 63 | public CompletableFuture getSingle(String query, String column, Class clazz) { 64 | if (clazz == null) { 65 | return null; 66 | } 67 | return getSingle(query, column); 68 | } 69 | 70 | public CompletableFuture>> withRows(String query, BiConsumer, List> consumer) { 71 | return getResult(query, consumer); 72 | } 73 | 74 | public CompletableFuture>> execute(String query) { 75 | return getResult(query); 76 | } 77 | 78 | public void done() { 79 | dbClient.getMessageBus().getDataProducer().processRequest(new Disconnect(dbClient)); 80 | } 81 | 82 | public void open() { 83 | ++useCount; 84 | } 85 | 86 | @Override 87 | public void close() { 88 | if (--useCount == 0) { 89 | done(); 90 | } 91 | } 92 | 93 | public void cleanIfDone() { 94 | if (!dbClient.getSession().isConnected()) { 95 | var session = dbClient.getSession(); 96 | DatabaseSessionClient[] clients = session.getClients(); 97 | for (DatabaseSessionClient client : clients) { 98 | session.detach(client); 99 | } 100 | Disposer.dispose(session); 101 | } 102 | } 103 | 104 | /** 105 | * Can be used in the exceptionally method of a CompletableFuture 106 | * 107 | * @param t The exception that is thrown, will be ignored 108 | */ 109 | @SuppressWarnings({"unused", "SameReturnValue"}) // param t 110 | public T close(Throwable t) { 111 | close(); 112 | return null; 113 | } 114 | 115 | public T closeAndReturn(T t) { 116 | close(); 117 | return t; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/AppSettingsComponent.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui; 2 | 3 | import com.intellij.ui.components.JBCheckBox; 4 | import com.intellij.ui.components.JBLabel; 5 | import com.intellij.ui.components.JBTextField; 6 | import com.intellij.util.ui.FormBuilder; 7 | import com.intellij.util.ui.UIUtil.ComponentStyle; 8 | import com.intellij.util.ui.UIUtil.FontColor; 9 | import dev.niels.sqlbackuprestore.AppSettingsState; 10 | import lombok.Getter; 11 | import org.apache.commons.lang3.math.NumberUtils; 12 | 13 | import javax.swing.JPanel; 14 | 15 | public class AppSettingsComponent { 16 | @Getter 17 | private final JPanel mainPanel; 18 | private final JBTextField compressionSize = new JBTextField(); 19 | private final JBCheckBox useCompressedBackup = new JBCheckBox("Use compressed backups"); 20 | private final JBCheckBox useDbNameOnDownload = new JBCheckBox("Use DB name on backup and download"); 21 | private final JBCheckBox askForRestoreFileLocations = new JBCheckBox("Ask for file locations when restoring"); 22 | private final JBCheckBox enableDownloadOption = new JBCheckBox("Enable 'Backup and Download' option"); 23 | 24 | public AppSettingsComponent() { 25 | mainPanel = FormBuilder.createFormBuilder() 26 | .addComponent(useCompressedBackup) 27 | .addComponent(new JBLabel("This is supported for SQL2008+", ComponentStyle.SMALL, FontColor.BRIGHTER)) 28 | .addVerticalGap(1) 29 | .addLabeledComponent("Ask for custom compression when downloading file bigger than (MB)", compressionSize) 30 | .addComponent(new JBLabel("0 or empty to always ask.", ComponentStyle.SMALL, FontColor.BRIGHTER)) 31 | .addComponent(new JBLabel("Having the database compress the file will be faster but this might make the backup slightly smaller.", ComponentStyle.SMALL, FontColor.BRIGHTER)) 32 | .addVerticalGap(1) 33 | .addComponent(useDbNameOnDownload) 34 | .addComponent(new JBLabel("By default, the name of the backup filename will be used", ComponentStyle.SMALL, FontColor.BRIGHTER)) 35 | .addVerticalGap(1) 36 | .addComponent(askForRestoreFileLocations) 37 | .addVerticalGap(1) 38 | .addComponent(enableDownloadOption) 39 | .addComponent(new JBLabel("Can be used to download a backup from a remote database, not very useful for local database servers", ComponentStyle.SMALL, FontColor.BRIGHTER)) 40 | .addComponentFillVertically(new JPanel(), 0) 41 | .getPanel(); 42 | } 43 | 44 | public boolean isModified() { 45 | var current = AppSettingsState.getInstance(); 46 | var modified = !parse(compressionSize.getText()).equals(current.getCompressionSize()); 47 | modified |= useCompressedBackup.isSelected() != current.isUseCompressedBackup(); 48 | modified |= useDbNameOnDownload.isSelected() != current.isUseDbNameOnDownload(); 49 | modified |= askForRestoreFileLocations.isSelected() != current.isAskForRestoreFileLocations(); 50 | modified |= enableDownloadOption.isSelected() != current.isEnableDownloadOption(); 51 | return modified; 52 | } 53 | 54 | private Long parse(String in) { 55 | try { 56 | return NumberUtils.createNumber(in).longValue(); 57 | } catch (NumberFormatException e) { 58 | return 0L; 59 | } 60 | } 61 | 62 | public void apply() { 63 | var current = AppSettingsState.getInstance(); 64 | current.setCompressionSize(parse(compressionSize.getText())); 65 | current.setUseCompressedBackup(useCompressedBackup.isSelected()); 66 | current.setUseDbNameOnDownload(useDbNameOnDownload.isSelected()); 67 | current.setAskForRestoreFileLocations(askForRestoreFileLocations.isSelected()); 68 | current.setEnableDownloadOption(enableDownloadOption.isSelected()); 69 | } 70 | 71 | public void reset() { 72 | var current = AppSettingsState.getInstance(); 73 | compressionSize.setText(current.getCompressionSize() == 0L ? "" : "" + current.getCompressionSize()); 74 | useCompressedBackup.setSelected(current.isUseCompressedBackup()); 75 | useDbNameOnDownload.setSelected(current.isUseDbNameOnDownload()); 76 | askForRestoreFileLocations.setSelected(current.isAskForRestoreFileLocations()); 77 | enableDownloadOption.setSelected(current.isEnableDownloadOption()); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/filedialog/RemoteFile.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui.filedialog; 2 | 3 | import com.intellij.openapi.vfs.VirtualFile; 4 | import com.intellij.openapi.vfs.VirtualFileSystem; 5 | import dev.niels.sqlbackuprestore.query.Client; 6 | import dev.niels.sqlbackuprestore.ui.SQLHelper; 7 | import lombok.Getter; 8 | import lombok.Setter; 9 | import lombok.SneakyThrows; 10 | import lombok.experimental.Accessors; 11 | import org.apache.commons.lang3.StringUtils; 12 | import org.jetbrains.annotations.Contract; 13 | import org.jetbrains.annotations.NotNull; 14 | import org.jetbrains.annotations.Nullable; 15 | 16 | import java.io.InputStream; 17 | import java.io.OutputStream; 18 | 19 | /** 20 | * Tree navigation from the connection 21 | */ 22 | public class RemoteFile extends VirtualFile { 23 | private final DatabaseFileSystem databaseFileSystem; 24 | private final RemoteFile parent; 25 | private final String path; 26 | private final boolean directory; 27 | @Getter 28 | private final boolean exists; 29 | private VirtualFile[] children; 30 | @Getter 31 | @Setter 32 | @Accessors(chain = true) 33 | private long length; 34 | 35 | public RemoteFile(DatabaseFileSystem databaseFileSystem, RemoteFile parent, String path, boolean directory, boolean exists) { 36 | this.databaseFileSystem = databaseFileSystem; 37 | this.parent = parent; 38 | this.path = StringUtils.stripEnd(path, "\\"); 39 | this.directory = directory; 40 | this.exists = exists; 41 | } 42 | 43 | @NotNull 44 | @Override 45 | public String getName() { 46 | var idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); 47 | if (idx == -1) { 48 | return path; 49 | } 50 | return path.substring(idx + 1); 51 | } 52 | 53 | @NotNull 54 | @Override 55 | public VirtualFileSystem getFileSystem() { 56 | return databaseFileSystem; 57 | } 58 | 59 | @NotNull 60 | @Override 61 | public String getPath() { 62 | return path; 63 | } 64 | 65 | @Override 66 | public boolean isWritable() { 67 | return true; 68 | } 69 | 70 | @Override 71 | public boolean isDirectory() { 72 | return directory; 73 | } 74 | 75 | @Override 76 | public boolean isValid() { 77 | return true; 78 | } 79 | 80 | @Override 81 | public VirtualFile getParent() { 82 | return parent; 83 | } 84 | 85 | @SneakyThrows 86 | @Override 87 | public VirtualFile[] getChildren() { 88 | if (children == null) { 89 | if (isDirectory()) { 90 | children = SQLHelper.getSQLPathChildren(databaseFileSystem.getConnection(), path).stream().map(r -> new RemoteFile(databaseFileSystem, this, r.get("FullName").toString(), !Integer.valueOf(1).equals(r.get("IsFile")), true)).toArray(RemoteFile[]::new); 91 | } else { 92 | children = new VirtualFile[]{}; 93 | } 94 | } 95 | return children; 96 | } 97 | 98 | @Contract("_, true -> !null") 99 | public VirtualFile getChild(String name, boolean nonExistingIfNotFound) { 100 | for (VirtualFile child : getChildren()) { 101 | if (name.equals(child.getName())) { 102 | return child; 103 | } 104 | } 105 | 106 | if (nonExistingIfNotFound) { 107 | return new RemoteFile((DatabaseFileSystem) getFileSystem(), this, getPath() + "\\" + name, false, false); 108 | } 109 | return null; 110 | } 111 | 112 | @NotNull @Override 113 | public OutputStream getOutputStream(Object requestor, long newModificationStamp, long newTimeStamp) { 114 | throw new IllegalStateException("Unable to get output stream"); 115 | } 116 | 117 | @Override 118 | public byte @NotNull [] contentsToByteArray() { 119 | return new byte[0]; 120 | } 121 | 122 | @Override 123 | public long getTimeStamp() { 124 | return 0; 125 | } 126 | 127 | @Override 128 | public void refresh(boolean asynchronous, boolean recursive, @Nullable Runnable postRunnable) { 129 | if (children != null) { 130 | children = null; 131 | getChildren(); 132 | if (postRunnable != null) { 133 | postRunnable.run(); 134 | } 135 | } 136 | } 137 | 138 | @NotNull @Override 139 | public InputStream getInputStream() { 140 | throw new IllegalStateException("Unable to get input stream"); 141 | } 142 | 143 | @Override 144 | public boolean exists() { 145 | return isExists(); 146 | } 147 | 148 | public Client getConnection() { 149 | return databaseFileSystem.getConnection(); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for testing and preparing the plugin release in following steps: 2 | # - validate Gradle Wrapper, 3 | # - run 'test' and 'verifyPlugin' tasks, 4 | # - run Qodana inspections, 5 | # - run 'buildPlugin' task and prepare artifact for the further tests, 6 | # - run 'runPluginVerifier' task, 7 | # - create a draft release. 8 | # 9 | # Workflow is triggered on push and pull_request events. 10 | # 11 | # GitHub Actions reference: https://help.github.com/en/actions 12 | # 13 | ## JBIJPPTPL 14 | 15 | name: Build 16 | on: 17 | # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g. for dependabot pull requests) 18 | push: 19 | branches: [ master ] 20 | # Trigger the workflow on any pull request 21 | pull_request: 22 | workflow_dispatch: 23 | 24 | jobs: 25 | 26 | # Run Gradle Wrapper Validation Action to verify the wrapper's checksum 27 | # Run verifyPlugin, IntelliJ Plugin Verifier, and test Gradle tasks 28 | # Build plugin and provide the artifact for the next workflow jobs 29 | build: 30 | name: Build 31 | runs-on: ubuntu-latest 32 | outputs: 33 | version: ${{ steps.properties.outputs.version }} 34 | changelog: ${{ steps.properties.outputs.changelog }} 35 | steps: 36 | # Free GitHub Actions Environment Disk Space 37 | - name: Maximize Build Space 38 | run: | 39 | sudo rm -rf /usr/share/dotnet 40 | sudo rm -rf /usr/local/lib/android 41 | sudo rm -rf /opt/ghc 42 | 43 | # Check out current repository 44 | - name: Fetch Sources 45 | uses: actions/checkout@v5 46 | 47 | # Validate wrapper 48 | - name: Gradle Wrapper Validation 49 | uses: gradle/actions/wrapper-validation@v4 50 | 51 | # Setup Java 17 environment for the next steps 52 | - name: Setup Java 53 | uses: actions/setup-java@v5 54 | with: 55 | distribution: zulu 56 | java-version: 17 57 | cache: gradle 58 | 59 | # Setup Gradle 60 | - name: Setup Gradle 61 | uses: gradle/actions/setup-gradle@v4 62 | with: 63 | cache-cleanup: 'always' 64 | 65 | # Set environment variables 66 | - name: Export Properties 67 | id: properties 68 | shell: bash 69 | run: | 70 | PROPERTIES="$(./gradlew properties --console=plain -q)" 71 | VERSION="$(echo "$PROPERTIES" | grep "^version:" | cut -f2- -d ' ')" 72 | NAME="$(echo "$PROPERTIES" | grep "^pluginName:" | cut -f2- -d ' ')" 73 | CHANGELOG="$(./gradlew getChangelog --unreleased --no-header --console=plain -q)" 74 | 75 | echo "version=$VERSION" >> $GITHUB_OUTPUT 76 | echo "name=$NAME" >> $GITHUB_OUTPUT 77 | echo "pluginVerifierHomeDir=~/.pluginVerifier" >> $GITHUB_OUTPUT 78 | 79 | echo "changelog<> $GITHUB_OUTPUT 80 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 81 | echo "EOF" >> $GITHUB_OUTPUT 82 | 83 | ./gradlew printProductsReleases # prepare list of IDEs for Plugin Verifier 84 | 85 | # Build plugin 86 | - name: Build plugin 87 | run: ./gradlew buildPlugin 88 | 89 | # Prepare plugin archive content for creating artifact 90 | - name: Prepare Plugin Artifact 91 | id: artifact 92 | shell: bash 93 | run: | 94 | cd ${{ github.workspace }}/build/distributions 95 | FILENAME=`ls *.zip` 96 | unzip "$FILENAME" -d content 97 | 98 | echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT 99 | 100 | # Store already-built plugin as an artifact for downloading 101 | - name: Upload artifact 102 | uses: actions/upload-artifact@v4 103 | with: 104 | name: ${{ steps.artifact.outputs.filename }} 105 | path: ./build/distributions/content/*/* 106 | 107 | 108 | # Prepare a draft release for GitHub Releases page for the manual verification 109 | # If accepted and published, release workflow would be triggered 110 | releaseDraft: 111 | name: Release Draft 112 | if: github.event_name != 'pull_request' 113 | needs: build 114 | runs-on: ubuntu-latest 115 | steps: 116 | 117 | # Check out current repository 118 | - name: Fetch Sources 119 | uses: actions/checkout@v5 120 | 121 | # Remove old release drafts by using the curl request for the available releases with draft flag 122 | - name: Remove Old Release Drafts 123 | env: 124 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 125 | run: | 126 | gh api repos/{owner}/{repo}/releases \ 127 | --jq '.[] | select(.draft == true) | .id' \ 128 | | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} 129 | 130 | # Create new release draft - which is not publicly visible and requires manual acceptance 131 | - name: Create Release Draft 132 | env: 133 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 134 | run: | 135 | gh release create v${{ needs.build.outputs.version }} \ 136 | --draft \ 137 | --title "v${{ needs.build.outputs.version }}" \ 138 | --notes "$(cat << 'EOM' 139 | ${{ needs.build.outputs.changelog }} 140 | EOM 141 | )" 142 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/SQLHelper.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui; 2 | 3 | import dev.niels.sqlbackuprestore.query.Client; 4 | import lombok.SneakyThrows; 5 | 6 | import java.util.List; 7 | import java.util.Map; 8 | import java.util.concurrent.TimeUnit; 9 | 10 | public interface SQLHelper { 11 | @SneakyThrows 12 | static String getDefaultBackupDirectory(Client connection) { 13 | return (String) connection.getSingle("declare @BackupDirectory nvarchar(512)\n" + 14 | "if 1=isnull(cast(SERVERPROPERTY('IsLocalDB') as bit), 0)\n" + 15 | "select @BackupDirectory=cast(SERVERPROPERTY('instancedefaultdatapath') as nvarchar(512))\n" + 16 | "else\n" + 17 | "exec master.dbo.xp_instance_regread N'HKEY_LOCAL_MACHINE', N'SOFTWARE\\Microsoft\\MSSQLServer\\MSSQLServer', N'BackupDirectory', @BackupDirectory OUTPUT\n" + 18 | "\n" + 19 | "select @BackupDirectory as directory", "directory").get(2, TimeUnit.SECONDS); 20 | } 21 | 22 | @SneakyThrows 23 | static List> getDrives(Client connection) { 24 | return connection.getResult("create table #fixdrv ( Name sysname NOT NULL, Size int NOT NULL, Type sysname NULL )\n" + 25 | "if exists (select 1 from sys.all_objects where name='dm_os_enumerate_fixed_drives' and type ='V' and is_ms_shipped = 1)\n" + 26 | "begin\n" + 27 | " insert #fixdrv select fixed_drive_path, free_space_in_bytes/(1024*1024), drive_type_desc from sys.dm_os_enumerate_fixed_drives \n" + 28 | "end\n" + 29 | "else\n" + 30 | "begin\n" + 31 | " insert #fixdrv (Name, Size) EXECUTE master.dbo.xp_fixeddrives \n" + 32 | " update #fixdrv set Name = Name + ':/', Type = 'Fixed' where Type IS NULL \n" + 33 | "end\n" + 34 | "select * from #fixdrv;\n" + 35 | "drop table #fixdrv;").get(10, TimeUnit.SECONDS); 36 | } 37 | 38 | @SneakyThrows 39 | static List> getSQLPathChildren(Client connection, String path) { 40 | return connection.getResult("declare @Path nvarchar(255)\n" + 41 | "declare @Name nvarchar(255)\n" + 42 | "select @Path = N'" + path + "'\n" + 43 | "select @Name = null;\n" + 44 | "\n" + 45 | "create table #filetmpfin (Name nvarchar(255) NOT NULL, IsFile int NULL, FullName nvarchar(300) not NULL)\n" + 46 | "declare @FullName nvarchar(300) \n" + 47 | "if exists (select 1 from sys.all_objects where name = 'dm_os_enumerate_filesystem' and type = 'IF' and is_ms_shipped = 1)\n" + 48 | "begin \n" + 49 | " if (@Name is null)\n" + 50 | " begin \n" + 51 | " insert #filetmpfin select file_or_directory_name, 1 - is_directory, full_filesystem_path from sys.dm_os_enumerate_filesystem(@Path, '*') where [level] = 0\n" + 52 | " end \n" + 53 | " if (NOT @Name is null)\n" + 54 | " begin \n" + 55 | " if(@Path is null) \n" + 56 | " select @FullName = @Name \n" + 57 | " else\n" + 58 | " select @FullName = @Path \t+ convert(nvarchar(1), serverproperty('PathSeparator')) + @Name \n" + 59 | " create table #filetmp3 ( Exist bit NOT NULL, IsDir bit NOT NULL, DirExist bit NULL ) \n" + 60 | " insert #filetmp3 select file_exists, file_is_a_directory, parent_directory_exists from sys.dm_os_file_exists(@FullName) \n" + 61 | " insert #filetmpfin select @Name, 1-IsDir, @FullName from #filetmp3 where Exist = 1 or IsDir = 1 \n" + 62 | " drop table #filetmp3 \n" + 63 | " end\n" + 64 | "end \n" + 65 | "else \n" + 66 | "begin \n" + 67 | " if(@Name is null)\n" + 68 | " begin\n" + 69 | " if (right(@Path, 1) = '\\')\n" + 70 | " select @Path= substring(@Path, 1, len(@Path) - charindex('\\', reverse(@Path)))\n" + 71 | " create table #filetmp (Name nvarchar(255) NOT NULL, depth int NOT NULL, IsFile bit NULL )\n" + 72 | " insert #filetmp EXECUTE master.dbo.xp_dirtree @Path, 1, 1\n" + 73 | " insert #filetmpfin select Name, IsFile, @Path + '\\' + Name from #filetmp f\n" + 74 | " drop table #filetmp\n" + 75 | " end \n" + 76 | " if(NOT @Name is null)\n" + 77 | " begin\n" + 78 | " if(@Path is null)\n" + 79 | " select @FullName = @Name\n" + 80 | " else\n" + 81 | " select @FullName = @Path + '\\' + @Name\n" + 82 | " if (right(@FullName, 1) = '\\')\n" + 83 | " select @Path= substring(@Path, 1, len(@FullName) - charindex('\\', reverse(@FullName)))\n" + 84 | " create table #filetmp2 ( Exist bit NOT NULL, IsDir bit NOT NULL, DirExist bit NULL )\n" + 85 | " insert #filetmp2 EXECUTE master.dbo.xp_fileexist @FullName\n" + 86 | " insert #filetmpfin select @Name, 1-IsDir, @FullName from #filetmp2 where Exist = 1 or IsDir = 1 \n" + 87 | " drop table #filetmp2\n" + 88 | " end \n" + 89 | "end \n" + 90 | "\n" + 91 | "SELECT Name, IsFile, FullName FROM #filetmpfin ORDER BY IsFile ASC, Name ASC \n" + 92 | "drop table #filetmpfin").get(10, TimeUnit.SECONDS); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/action/Backup.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.action; 2 | 3 | import com.intellij.notification.Notification; 4 | import com.intellij.notification.NotificationType; 5 | import com.intellij.notification.Notifications.Bus; 6 | import com.intellij.openapi.actionSystem.ActionUpdateThread; 7 | import com.intellij.openapi.actionSystem.AnActionEvent; 8 | import com.intellij.openapi.application.ApplicationManager; 9 | import com.intellij.openapi.project.DumbAwareAction; 10 | import dev.niels.sqlbackuprestore.AppSettingsState; 11 | import dev.niels.sqlbackuprestore.Constants; 12 | import dev.niels.sqlbackuprestore.query.Auditor.MessageType; 13 | import dev.niels.sqlbackuprestore.query.Client; 14 | import dev.niels.sqlbackuprestore.query.ProgressTask; 15 | import dev.niels.sqlbackuprestore.query.QueryHelper; 16 | import dev.niels.sqlbackuprestore.ui.filedialog.FileDialog; 17 | import dev.niels.sqlbackuprestore.ui.filedialog.RemoteFile; 18 | import lombok.extern.slf4j.Slf4j; 19 | import org.apache.commons.lang3.StringUtils; 20 | import org.jetbrains.annotations.NotNull; 21 | 22 | import java.util.Set; 23 | import java.util.concurrent.CompletableFuture; 24 | 25 | /** 26 | * Backup database to a file 27 | */ 28 | @Slf4j 29 | public class Backup extends DumbAwareAction { 30 | // https://docs.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql?view=sql-server-ver15 31 | // https://docs.microsoft.com/en-us/sql/sql-server/editions-and-components-of-sql-server-version-15?view=sql-server-ver15#RDBMSHA 32 | private static final Set editionIdsWithoutCompressionSupport = Set.of( 33 | "-1592396055", // Express 34 | "-133711905", // Express with Advanced Services 35 | "1293598313" // Web 36 | ); 37 | 38 | @Override 39 | public @NotNull ActionUpdateThread getActionUpdateThread() { 40 | return ActionUpdateThread.BGT; 41 | } 42 | 43 | @Override 44 | public void actionPerformed(@NotNull AnActionEvent e) { 45 | ApplicationManager.getApplication().invokeLater(() -> { 46 | try (var c = QueryHelper.client(e)) { 47 | c.setTitle("Backup database"); 48 | backup(e, c); 49 | } 50 | }); 51 | } 52 | 53 | @Override 54 | public void update(@NotNull AnActionEvent e) { 55 | e.getPresentation().setEnabled(QueryHelper.getDatabase(e).isPresent()); 56 | } 57 | 58 | /** 59 | * Asks for a (remote) file and backs the selected database up to that file. 60 | * Must be called on the event thread. 61 | * 62 | * @param e the event that triggered the action (the database is retrieved from the action) 63 | * @param c the connection that should be used for backing up (will be taken over if a backup is being made, close it from the future as well). 64 | * @return a pair of the connection that should be closed and the file that was being selected. The original connection and null if no file was selected. 65 | */ 66 | protected CompletableFuture backup(@NotNull AnActionEvent e, Client c) { 67 | var database = QueryHelper.getDatabase(e); 68 | if (database.isEmpty()) { 69 | return CompletableFuture.completedFuture(null); 70 | } 71 | 72 | var name = database.get().getName(); 73 | var target = FileDialog.saveFile(name + ".bak", e.getProject(), c, "Backup " + database.get() + " to file"); 74 | if (target == null) { 75 | return CompletableFuture.completedFuture(null); 76 | } 77 | 78 | c.open(); 79 | c.setTitle("Backup " + name); 80 | 81 | var future = determineCompression(c) 82 | .thenCompose(compress -> c.execute("BACKUP DATABASE [" + name + "] TO DISK = N'" + target.getPath() + "' WITH COPY_ONLY, NOFORMAT, INIT, SKIP, NOREWIND, NOUNLOAD" + compress + ", STATS = 10")) 83 | .thenApply(c::closeAndReturn) 84 | .exceptionally(c::close) 85 | .thenCompose(x -> c.getSingle(String.format("USE [%s] exec sp_spaceused @oneresultset = 1", name), "reserved", String.class) 86 | .thenApply(kb -> Long.parseLong(StringUtils.removeEnd(kb, " KB")) * 1024) 87 | .thenApply(target::setLength)); 88 | 89 | c.addWarningConsumer((type, msg) -> { 90 | if (type == MessageType.ERROR) { 91 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, "Error occurred", msg, NotificationType.ERROR)); 92 | } 93 | }); 94 | 95 | new ProgressTask(e.getProject(), "Creating backup", false, consumer -> { 96 | c.addWarningConsumer(consumer); 97 | try { 98 | future.get(); 99 | } catch (Exception ex) { 100 | // Don't really care ;) 101 | } 102 | }).queue(); 103 | return future; 104 | } 105 | 106 | private CompletableFuture determineCompression(Client c) { 107 | if (!AppSettingsState.getInstance().isUseCompressedBackup()) { 108 | return CompletableFuture.completedFuture(""); 109 | } 110 | return c.getSingle("SELECT cast(SERVERPROPERTY('EditionID') as varchar(20)) AS edition", "edition") // EditionID is supposed to be a bigint but returns as String. Cast to be super sure. 111 | .thenApply(id -> { 112 | var result = !editionIdsWithoutCompressionSupport.contains(id); 113 | log.info("Version {} does {}support compression", id, result ? "" : "not "); 114 | return result; 115 | }) 116 | .thenApply(compress -> compress ? ", COMPRESSION" : ""); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/ui/filedialog/FileDialog.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.ui.filedialog; 2 | 3 | import com.intellij.ide.util.PropertiesComponent; 4 | import com.intellij.notification.Notification; 5 | import com.intellij.notification.NotificationType; 6 | import com.intellij.notification.Notifications.Bus; 7 | import com.intellij.openapi.fileChooser.FileChooserDescriptor; 8 | import com.intellij.openapi.fileChooser.FileChooserFactory; 9 | import com.intellij.openapi.fileChooser.FileSaverDescriptor; 10 | import com.intellij.openapi.project.Project; 11 | import com.intellij.openapi.vfs.VirtualFile; 12 | import dev.niels.sqlbackuprestore.Constants; 13 | import dev.niels.sqlbackuprestore.query.Client; 14 | import dev.niels.sqlbackuprestore.ui.SQLHelper; 15 | import lombok.RequiredArgsConstructor; 16 | import one.util.streamex.StreamEx; 17 | import org.apache.commons.lang3.ArrayUtils; 18 | import org.apache.commons.lang3.StringUtils; 19 | import org.jetbrains.annotations.NotNull; 20 | import org.jetbrains.annotations.Nullable; 21 | 22 | import java.util.Optional; 23 | 24 | import static dev.niels.sqlbackuprestore.ui.filedialog.DialogType.LOAD; 25 | import static dev.niels.sqlbackuprestore.ui.filedialog.DialogType.SAVE; 26 | 27 | /** 28 | * File dialog to show remote files from SQLServer. 29 | */ 30 | @RequiredArgsConstructor 31 | public class FileDialog { 32 | public static final String KEY_PREFIX = "sqlserver_backup_path_"; 33 | private static final String DESCRIPTION = "This file picker shows files from the SQLServer instance, this might not be your local filesystem."; 34 | private final Project project; 35 | private final Client connection; 36 | private final String title; 37 | 38 | /** 39 | * Open the file dialog to show files from the connection. 40 | */ 41 | public static RemoteFile[] chooseFiles(String fileName, Project project, Client c, String title) { 42 | return new FileDialog(project, c, title).choose(LOAD, fileName); 43 | } 44 | 45 | public static RemoteFile saveFile(String fileName, Project project, Client c, String title) { 46 | return ArrayUtils.get(new FileDialog(project, c, title).choose(SAVE, fileName), 0); 47 | } 48 | 49 | private RemoteFile[] choose(DialogType type, String fileName) { 50 | var fs = new DatabaseFileSystem(connection); 51 | var roots = fs.getRoots(); 52 | 53 | if (roots.length == 0) { 54 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, "Error occurred", "The database user for this connection is not allowed to read drives.", NotificationType.ERROR)); 55 | return null; 56 | } 57 | 58 | var initial = getInitial(roots); 59 | 60 | if (type == LOAD) { 61 | return loadFile(roots, initial); 62 | } else { 63 | return saveFile(fileName, roots, initial); 64 | } 65 | } 66 | 67 | private @NotNull RemoteFile @NotNull [] loadFile(VirtualFile[] roots, RemoteFile initial) { 68 | var descriptor = new FileChooserDescriptor(true, false, false, false, false, true) 69 | .withRoots(roots); 70 | descriptor.setTitle(title); 71 | descriptor.setDescription(DESCRIPTION); 72 | descriptor.setForcedToUseIdeaFileChooser(true); 73 | 74 | var chooser = FileChooserFactory.getInstance().createFileChooser(descriptor, project, null); 75 | var choice = initial == null ? chooser.choose(project) : chooser.choose(project, initial, initial); 76 | 77 | var result = StreamEx.of(choice).select(RemoteFile.class).toArray(RemoteFile[]::new); 78 | if (result.length > 0) { 79 | PropertiesComponent.getInstance(project).setValue(getSelectionKeyName(result[0].getConnection()), result[0].getPath()); 80 | } 81 | return result; 82 | } 83 | 84 | private @NotNull RemoteFile @NotNull [] saveFile(String fileName, VirtualFile[] roots, RemoteFile initial) { 85 | var descriptor = (FileSaverDescriptor) new FileSaverDescriptor(title, DESCRIPTION).withRoots(roots); 86 | descriptor.setForcedToUseIdeaFileChooser(true); 87 | var file = new Chooser(descriptor, project).choose(initial, fileName); 88 | if (file != null) { 89 | return new RemoteFile[]{file}; 90 | } 91 | return new RemoteFile[0]; 92 | } 93 | 94 | private RemoteFile getInitial(VirtualFile[] roots) { 95 | var path = PropertiesComponent.getInstance(project).getValue(getSelectionKeyName(connection)); 96 | RemoteFile current = getRemoteFile(roots, path); 97 | if (current != null && current.exists()) { 98 | return current; 99 | } 100 | if (current != null && current.getParent() != null && current.getParent().exists()) { 101 | return (RemoteFile) current.getParent(); 102 | } 103 | 104 | try { 105 | var backupDirectory = SQLHelper.getDefaultBackupDirectory(connection); 106 | return getRemoteFile(roots, backupDirectory); 107 | } catch (Exception e) { 108 | return null; 109 | } 110 | } 111 | 112 | @Nullable 113 | private RemoteFile getRemoteFile(VirtualFile[] roots, String path) { 114 | var parts = StringUtils.defaultIfBlank(path, "").split("[\\\\/]"); 115 | var finalParts = parts.length > 0 ? parts : new String[]{"/"}; 116 | 117 | for (VirtualFile root : roots) { 118 | if (!root.getName().equals(finalParts[0])) { 119 | continue; 120 | } 121 | 122 | var current = Optional.of(root); 123 | for (var i = 1; i < finalParts.length && current.isPresent(); i++) { 124 | var ic = i; 125 | current = current.map(c -> c instanceof RemoteFile rf ? rf.getChild(finalParts[ic], true) : c.findChild(finalParts[ic])); 126 | } 127 | 128 | if (current.isPresent()) { 129 | return (RemoteFile) current.get(); 130 | } 131 | } 132 | return null; 133 | } 134 | 135 | @NotNull 136 | static String getSelectionKeyName(@NotNull Client connection) { 137 | return KEY_PREFIX + connection.getDbName(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=`expr $i + 1` 158 | done 159 | case $i in 160 | 0) set -- ;; 161 | 1) set -- "$args0" ;; 162 | 2) set -- "$args0" "$args1" ;; 163 | 3) set -- "$args0" "$args1" "$args2" ;; 164 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=`save "$@"` 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | exec "$JAVACMD" "$@" 184 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/action/Download.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.action; 2 | 3 | import com.intellij.database.model.DasObject; 4 | import com.intellij.database.remote.jdbc.RemoteBlob; 5 | import com.intellij.ide.util.PropertiesComponent; 6 | import com.intellij.notification.Notification; 7 | import com.intellij.notification.NotificationType; 8 | import com.intellij.notification.Notifications.Bus; 9 | import com.intellij.openapi.actionSystem.ActionUpdateThread; 10 | import com.intellij.openapi.actionSystem.AnActionEvent; 11 | import com.intellij.openapi.application.ApplicationManager; 12 | import com.intellij.openapi.fileChooser.FileChooserFactory; 13 | import com.intellij.openapi.fileChooser.FileSaverDescriptor; 14 | import com.intellij.openapi.progress.ProgressIndicator; 15 | import com.intellij.openapi.progress.Task.Backgroundable; 16 | import com.intellij.openapi.project.DumbAwareAction; 17 | import com.intellij.openapi.project.Project; 18 | import com.intellij.openapi.ui.Messages; 19 | import com.intellij.openapi.vfs.LocalFileSystem; 20 | import dev.niels.sqlbackuprestore.AppSettingsState; 21 | import dev.niels.sqlbackuprestore.Constants; 22 | import dev.niels.sqlbackuprestore.query.Client; 23 | import dev.niels.sqlbackuprestore.query.QueryHelper; 24 | import dev.niels.sqlbackuprestore.ui.filedialog.FileDialog; 25 | import lombok.extern.slf4j.Slf4j; 26 | import org.apache.commons.lang3.StringUtils; 27 | import org.jetbrains.annotations.NotNull; 28 | import org.jetbrains.annotations.Nullable; 29 | 30 | import java.io.File; 31 | import java.io.FileOutputStream; 32 | import java.io.IOException; 33 | import java.nio.file.Files; 34 | import java.sql.SQLException; 35 | import java.util.Objects; 36 | import java.util.concurrent.CompletableFuture; 37 | import java.util.concurrent.atomic.AtomicBoolean; 38 | 39 | /** 40 | * Triggers backup and then allows downloading the result 41 | */ 42 | public class Download extends DumbAwareAction { 43 | @Override 44 | public @NotNull ActionUpdateThread getActionUpdateThread() { 45 | return ActionUpdateThread.BGT; 46 | } 47 | 48 | @Override 49 | public void actionPerformed(@NotNull AnActionEvent e) { 50 | try (var c = QueryHelper.client(e)) { 51 | c.open(); 52 | 53 | ApplicationManager.getApplication().invokeLater(() -> 54 | new Backup().backup(e, c).thenAcceptAsync(source -> { 55 | if (source == null) { 56 | c.close(); 57 | return; 58 | } 59 | 60 | AtomicBoolean compressed = new AtomicBoolean(false); 61 | ApplicationManager.getApplication().invokeAndWait(() -> compressed.set(askCompress(e.getProject(), source.getLength()))); 62 | var col = compressed.get() ? "COMPRESS(BulkColumn)" : "BulkColumn"; 63 | 64 | c.execute("SELECT 1 as id, CAST(0 as bigint) AS fs, " + col + " AS f into #filedownload FROM OPENROWSET(BULK N'" + source.getPath() + "', SINGLE_BLOB) x;") 65 | .thenCompose(x -> c.execute("update #filedownload set fs = LEN(f) where id = 1;")) 66 | .thenRun(() -> ApplicationManager.getApplication().invokeLater(() -> { 67 | var name = source.getName() + (compressed.get() ? ".gzip" : ""); 68 | var target = getFile(e, name); 69 | if (target == null) { 70 | c.close(); 71 | return; 72 | } 73 | if (compressed.get() && !StringUtils.endsWithIgnoreCase(target.getAbsolutePath(), ".gzip")) { 74 | target = new File(target.getAbsolutePath() + ".gzip"); 75 | } 76 | new DownloadTask(e.getProject(), c, source.getPath(), target).queue(); 77 | })); 78 | }) 79 | ); 80 | } 81 | } 82 | 83 | @Nullable 84 | private File getFile(@NotNull AnActionEvent e, String fileName) { 85 | var property = PropertiesComponent.getInstance(Objects.requireNonNull(e.getProject())).getValue(FileDialog.KEY_PREFIX + "download"); 86 | var path = property == null ? null : LocalFileSystem.getInstance().findFileByPath(property); 87 | 88 | if (AppSettingsState.getInstance().isUseDbNameOnDownload()) { 89 | fileName = QueryHelper.getDatabase(e).map(DasObject::getName).orElse(null) + ".bak"; 90 | } 91 | var wrapper = FileChooserFactory.getInstance().createSaveFileDialog(new FileSaverDescriptor("Choose Local File", "Where to store the downloaded file"), e.getProject()).save(path, fileName); 92 | if (wrapper == null) { 93 | return null; 94 | } 95 | 96 | var result = wrapper.getFile(); 97 | PropertiesComponent.getInstance(e.getProject()).getValue(FileDialog.KEY_PREFIX + "download", result.getParent()); 98 | return result; 99 | } 100 | 101 | @Override 102 | public void update(@NotNull AnActionEvent e) { 103 | var isVisible = AppSettingsState.getInstance().isEnableDownloadOption(); 104 | e.getPresentation().setVisible(isVisible); 105 | if (isVisible) { 106 | e.getPresentation().setEnabled(QueryHelper.getDatabase(e).isPresent()); 107 | } 108 | } 109 | 110 | private boolean askCompress(Project project, Long size) { 111 | var compressed = AppSettingsState.getInstance().isUseCompressedBackup(); 112 | var askWhen = AppSettingsState.getInstance().getCompressionSize() * 1024 * 1024; 113 | if (size == null || askWhen <= size) { 114 | var message = compressed ? "The original database was %s before compression. Do you want to apply additional compression before downloading?" 115 | : "The database size is %s, do you want to compress the file before downloading?"; 116 | return Messages.YES == Messages.showYesNoDialog(project, 117 | String.format(message, size == null ? "?" : Util.humanReadableByteCountSI(size)), 118 | "Compress?", 119 | Messages.getQuestionIcon()); 120 | } 121 | return false; 122 | } 123 | 124 | @Slf4j 125 | private static class DownloadTask extends Backgroundable { 126 | private static final int CHUNK_SIZE = 1024 * 1024; 127 | private final Client connection; 128 | private final String path; 129 | private final File target; 130 | 131 | public DownloadTask(@Nullable Project project, Client connection, String path, File target) { 132 | super(project, "Downloading " + path); 133 | this.connection = connection; 134 | this.path = path; 135 | this.target = target; 136 | } 137 | 138 | @Override 139 | public void run(@NotNull ProgressIndicator indicator) { 140 | try (var fos = new FileOutputStream(target)) { 141 | indicator.setIndeterminate(false); 142 | indicator.setFraction(0.0); 143 | 144 | connection.getSingle("SELECT fs FROM #filedownload", "fs", Long.class) 145 | .thenCompose(s -> download(indicator, fos, s)) 146 | .exceptionally(connection::close) 147 | .thenRun(connection::close) 148 | .thenRun(() -> cleanIfCancelled(indicator)) 149 | .get(); 150 | } catch (Exception e) { 151 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, "Unable to write", "Unable to write to " + path + ":\n" + e.getMessage(), NotificationType.ERROR)); 152 | } 153 | } 154 | 155 | private CompletableFuture download(@NotNull ProgressIndicator indicator, FileOutputStream fos, Long s) { 156 | // Split into 100 parts unless the parts are smaller than 1MB 157 | var part = Math.max(1_000_000, (long) Math.ceil(s / 100d)); 158 | var parts = Math.ceil((double) s / part); 159 | 160 | CompletableFuture chain = CompletableFuture.completedFuture(null); 161 | 162 | // Build a chain of part downloads that are executed sequentially 163 | AtomicBoolean error = new AtomicBoolean(false); 164 | for (var i = 0; i < parts; i++) { 165 | var current = i; 166 | chain = chain.thenCompose(x -> { 167 | // Allow cancelling and don't proceed if there was an error 168 | if (error.get() || indicator.isCanceled()) { 169 | return CompletableFuture.completedFuture(null); 170 | } 171 | 172 | // Get the next part and store it 173 | return connection.withRows(String.format("select substring(f, %s, %s) AS part from #filedownload", current * part, part), (cols, rows) -> { 174 | try { 175 | write(fos, rows.get(0).getValue(0)); 176 | indicator.setFraction(current / parts); 177 | indicator.setText(String.format("%s: %s/%s", getTitle(), Util.humanReadableByteCountSI(Math.min(s, (current + 1) * part)), Util.humanReadableByteCountSI(s))); 178 | } catch (Exception e) { 179 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, "Unable to write", "Unable to write to " + target + ":\n" + e.getMessage(), NotificationType.ERROR)); 180 | error.set(true); 181 | } 182 | }); 183 | }); 184 | } 185 | return chain; 186 | } 187 | 188 | /** 189 | * Write a single part to the file stream 190 | */ 191 | private void write(FileOutputStream fos, Object blob) throws IOException, SQLException { 192 | if (blob instanceof RemoteBlob) { 193 | saveBlob(fos, (RemoteBlob) blob); 194 | } else if (blob instanceof byte[]) { 195 | saveBlob(fos, (byte[]) blob); 196 | } else if (blob instanceof String) { 197 | saveBlob(fos, (String) blob); // Haven't actually seen this happen... 198 | } else { 199 | throw new IllegalArgumentException("Unable to download column of type " + blob.getClass().getName()); 200 | } 201 | } 202 | 203 | /** 204 | * Check if the indicator was cancelled, if so delete the target file. 205 | */ 206 | private void cleanIfCancelled(ProgressIndicator indicator) { 207 | if (indicator.isCanceled()) { 208 | try { 209 | Files.delete(target.toPath()); 210 | } catch (IOException e) { 211 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, "Delete failure", "Unable to delete " + path + " after cancel:\n" + e.getMessage(), NotificationType.WARNING)); 212 | } 213 | } 214 | } 215 | 216 | /** 217 | * Write byte array to file 218 | */ 219 | private void saveBlob(FileOutputStream fos, byte[] blob) throws IOException { 220 | fos.write(blob); 221 | } 222 | 223 | /** 224 | * Write RemoteBlob to file 225 | */ 226 | private void saveBlob(FileOutputStream fos, RemoteBlob blob) throws IOException, SQLException { 227 | long position = 1; 228 | while (position < blob.length()) { 229 | fos.write(blob.getBytes(position, CHUNK_SIZE)); 230 | fos.flush(); 231 | position += CHUNK_SIZE; 232 | position = Math.min(blob.length(), position); 233 | } 234 | } 235 | 236 | /** 237 | * Write string to file 238 | */ 239 | private void saveBlob(FileOutputStream fos, String blob) throws IOException { 240 | saveBlob(fos, blob.getBytes()); 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = crlf 4 | indent_size = 4 5 | indent_style = space 6 | insert_final_newline = false 7 | max_line_length = 120 8 | tab_width = 4 9 | ij_continuation_indent_size = 8 10 | ij_formatter_off_tag = @formatter:off 11 | ij_formatter_on_tag = @formatter:on 12 | ij_formatter_tags_enabled = false 13 | ij_smart_tabs = false 14 | ij_visual_guides = none 15 | ij_wrap_on_typing = false 16 | 17 | [*.java] 18 | ij_java_align_consecutive_assignments = false 19 | ij_java_align_consecutive_variable_declarations = false 20 | ij_java_align_group_field_declarations = false 21 | ij_java_align_multiline_annotation_parameters = false 22 | ij_java_align_multiline_array_initializer_expression = false 23 | ij_java_align_multiline_assignment = false 24 | ij_java_align_multiline_binary_operation = false 25 | ij_java_align_multiline_chained_methods = false 26 | ij_java_align_multiline_extends_list = false 27 | ij_java_align_multiline_for = true 28 | ij_java_align_multiline_method_parentheses = false 29 | ij_java_align_multiline_parameters = true 30 | ij_java_align_multiline_parameters_in_calls = false 31 | ij_java_align_multiline_parenthesized_expression = false 32 | ij_java_align_multiline_records = true 33 | ij_java_align_multiline_resources = true 34 | ij_java_align_multiline_ternary_operation = false 35 | ij_java_align_multiline_text_blocks = false 36 | ij_java_align_multiline_throws_list = false 37 | ij_java_align_subsequent_simple_methods = false 38 | ij_java_align_throws_keyword = false 39 | ij_java_annotation_parameter_wrap = off 40 | ij_java_array_initializer_new_line_after_left_brace = false 41 | ij_java_array_initializer_right_brace_on_new_line = false 42 | ij_java_array_initializer_wrap = off 43 | ij_java_assert_statement_colon_on_next_line = false 44 | ij_java_assert_statement_wrap = off 45 | ij_java_assignment_wrap = off 46 | ij_java_binary_operation_sign_on_next_line = false 47 | ij_java_binary_operation_wrap = off 48 | ij_java_blank_lines_after_anonymous_class_header = 0 49 | ij_java_blank_lines_after_class_header = 0 50 | ij_java_blank_lines_after_imports = 1 51 | ij_java_blank_lines_after_package = 1 52 | ij_java_blank_lines_around_class = 1 53 | ij_java_blank_lines_around_field = 0 54 | ij_java_blank_lines_around_field_in_interface = 0 55 | ij_java_blank_lines_around_initializer = 1 56 | ij_java_blank_lines_around_method = 1 57 | ij_java_blank_lines_around_method_in_interface = 1 58 | ij_java_blank_lines_before_class_end = 0 59 | ij_java_blank_lines_before_imports = 1 60 | ij_java_blank_lines_before_method_body = 0 61 | ij_java_blank_lines_before_package = 0 62 | ij_java_block_brace_style = end_of_line 63 | ij_java_block_comment_at_first_column = true 64 | ij_java_call_parameters_new_line_after_left_paren = false 65 | ij_java_call_parameters_right_paren_on_new_line = false 66 | ij_java_call_parameters_wrap = off 67 | ij_java_case_statement_on_separate_line = true 68 | ij_java_catch_on_new_line = false 69 | ij_java_class_annotation_wrap = off 70 | ij_java_class_brace_style = end_of_line 71 | ij_java_class_count_to_use_import_on_demand = 99 72 | ij_java_class_names_in_javadoc = 1 73 | ij_java_do_not_indent_top_level_class_members = false 74 | ij_java_do_not_wrap_after_single_annotation = false 75 | ij_java_do_while_brace_force = never 76 | ij_java_doc_add_blank_line_after_description = true 77 | ij_java_doc_add_blank_line_after_param_comments = false 78 | ij_java_doc_add_blank_line_after_return = false 79 | ij_java_doc_add_p_tag_on_empty_lines = true 80 | ij_java_doc_align_exception_comments = true 81 | ij_java_doc_align_param_comments = true 82 | ij_java_doc_do_not_wrap_if_one_line = false 83 | ij_java_doc_enable_formatting = true 84 | ij_java_doc_enable_leading_asterisks = true 85 | ij_java_doc_indent_on_continuation = false 86 | ij_java_doc_keep_empty_lines = true 87 | ij_java_doc_keep_empty_parameter_tag = true 88 | ij_java_doc_keep_empty_return_tag = true 89 | ij_java_doc_keep_empty_throws_tag = true 90 | ij_java_doc_keep_invalid_tags = true 91 | ij_java_doc_param_description_on_new_line = false 92 | ij_java_doc_preserve_line_breaks = false 93 | ij_java_doc_use_throws_not_exception_tag = true 94 | ij_java_else_on_new_line = false 95 | ij_java_entity_dd_suffix = EJB 96 | ij_java_entity_eb_suffix = Bean 97 | ij_java_entity_hi_suffix = Home 98 | ij_java_entity_lhi_prefix = Local 99 | ij_java_entity_lhi_suffix = Home 100 | ij_java_entity_li_prefix = Local 101 | ij_java_entity_pk_class = java.lang.String 102 | ij_java_entity_vo_suffix = VO 103 | ij_java_enum_constants_wrap = off 104 | ij_java_extends_keyword_wrap = off 105 | ij_java_extends_list_wrap = off 106 | ij_java_field_annotation_wrap = off 107 | ij_java_finally_on_new_line = false 108 | ij_java_for_brace_force = never 109 | ij_java_for_statement_new_line_after_left_paren = false 110 | ij_java_for_statement_right_paren_on_new_line = false 111 | ij_java_for_statement_wrap = off 112 | ij_java_generate_final_locals = false 113 | ij_java_generate_final_parameters = false 114 | ij_java_if_brace_force = never 115 | ij_java_imports_layout = *,|,javax.**,java.**,|,$* 116 | ij_java_indent_case_from_switch = true 117 | ij_java_insert_inner_class_imports = false 118 | ij_java_insert_override_annotation = true 119 | ij_java_keep_blank_lines_before_right_brace = 2 120 | ij_java_keep_blank_lines_between_package_declaration_and_header = 2 121 | ij_java_keep_blank_lines_in_code = 2 122 | ij_java_keep_blank_lines_in_declarations = 2 123 | ij_java_keep_control_statement_in_one_line = true 124 | ij_java_keep_first_column_comment = true 125 | ij_java_keep_indents_on_empty_lines = false 126 | ij_java_keep_line_breaks = true 127 | ij_java_keep_multiple_expressions_in_one_line = false 128 | ij_java_keep_simple_blocks_in_one_line = false 129 | ij_java_keep_simple_classes_in_one_line = false 130 | ij_java_keep_simple_lambdas_in_one_line = false 131 | ij_java_keep_simple_methods_in_one_line = false 132 | ij_java_label_indent_absolute = false 133 | ij_java_label_indent_size = 0 134 | ij_java_lambda_brace_style = end_of_line 135 | ij_java_layout_static_imports_separately = true 136 | ij_java_line_comment_add_space = false 137 | ij_java_line_comment_at_first_column = true 138 | ij_java_message_dd_suffix = EJB 139 | ij_java_message_eb_suffix = Bean 140 | ij_java_method_annotation_wrap = off 141 | ij_java_method_brace_style = end_of_line 142 | ij_java_method_call_chain_wrap = off 143 | ij_java_method_parameters_new_line_after_left_paren = false 144 | ij_java_method_parameters_right_paren_on_new_line = false 145 | ij_java_method_parameters_wrap = off 146 | ij_java_modifier_list_wrap = false 147 | ij_java_names_count_to_use_import_on_demand = 99 148 | ij_java_new_line_after_lparen_in_record_header = false 149 | ij_java_parameter_annotation_wrap = off 150 | ij_java_parentheses_expression_new_line_after_left_paren = false 151 | ij_java_parentheses_expression_right_paren_on_new_line = false 152 | ij_java_place_assignment_sign_on_next_line = false 153 | ij_java_prefer_longer_names = true 154 | ij_java_prefer_parameters_wrap = false 155 | ij_java_record_components_wrap = normal 156 | ij_java_repeat_synchronized = true 157 | ij_java_replace_instanceof_and_cast = false 158 | ij_java_replace_null_check = true 159 | ij_java_replace_sum_lambda_with_method_ref = true 160 | ij_java_resource_list_new_line_after_left_paren = false 161 | ij_java_resource_list_right_paren_on_new_line = false 162 | ij_java_resource_list_wrap = off 163 | ij_java_rparen_on_new_line_in_record_header = false 164 | ij_java_session_dd_suffix = EJB 165 | ij_java_session_eb_suffix = Bean 166 | ij_java_session_hi_suffix = Home 167 | ij_java_session_lhi_prefix = Local 168 | ij_java_session_lhi_suffix = Home 169 | ij_java_session_li_prefix = Local 170 | ij_java_session_si_suffix = Service 171 | ij_java_space_after_closing_angle_bracket_in_type_argument = false 172 | ij_java_space_after_colon = true 173 | ij_java_space_after_comma = true 174 | ij_java_space_after_comma_in_type_arguments = true 175 | ij_java_space_after_for_semicolon = true 176 | ij_java_space_after_quest = true 177 | ij_java_space_after_type_cast = true 178 | ij_java_space_before_annotation_array_initializer_left_brace = false 179 | ij_java_space_before_annotation_parameter_list = false 180 | ij_java_space_before_array_initializer_left_brace = false 181 | ij_java_space_before_catch_keyword = true 182 | ij_java_space_before_catch_left_brace = true 183 | ij_java_space_before_catch_parentheses = true 184 | ij_java_space_before_class_left_brace = true 185 | ij_java_space_before_colon = true 186 | ij_java_space_before_colon_in_foreach = true 187 | ij_java_space_before_comma = false 188 | ij_java_space_before_do_left_brace = true 189 | ij_java_space_before_else_keyword = true 190 | ij_java_space_before_else_left_brace = true 191 | ij_java_space_before_finally_keyword = true 192 | ij_java_space_before_finally_left_brace = true 193 | ij_java_space_before_for_left_brace = true 194 | ij_java_space_before_for_parentheses = true 195 | ij_java_space_before_for_semicolon = false 196 | ij_java_space_before_if_left_brace = true 197 | ij_java_space_before_if_parentheses = true 198 | ij_java_space_before_method_call_parentheses = false 199 | ij_java_space_before_method_left_brace = true 200 | ij_java_space_before_method_parentheses = false 201 | ij_java_space_before_opening_angle_bracket_in_type_parameter = false 202 | ij_java_space_before_quest = true 203 | ij_java_space_before_switch_left_brace = true 204 | ij_java_space_before_switch_parentheses = true 205 | ij_java_space_before_synchronized_left_brace = true 206 | ij_java_space_before_synchronized_parentheses = true 207 | ij_java_space_before_try_left_brace = true 208 | ij_java_space_before_try_parentheses = true 209 | ij_java_space_before_type_parameter_list = false 210 | ij_java_space_before_while_keyword = true 211 | ij_java_space_before_while_left_brace = true 212 | ij_java_space_before_while_parentheses = true 213 | ij_java_space_inside_one_line_enum_braces = false 214 | ij_java_space_within_empty_array_initializer_braces = false 215 | ij_java_space_within_empty_method_call_parentheses = false 216 | ij_java_space_within_empty_method_parentheses = false 217 | ij_java_spaces_around_additive_operators = true 218 | ij_java_spaces_around_assignment_operators = true 219 | ij_java_spaces_around_bitwise_operators = true 220 | ij_java_spaces_around_equality_operators = true 221 | ij_java_spaces_around_lambda_arrow = true 222 | ij_java_spaces_around_logical_operators = true 223 | ij_java_spaces_around_method_ref_dbl_colon = false 224 | ij_java_spaces_around_multiplicative_operators = true 225 | ij_java_spaces_around_relational_operators = true 226 | ij_java_spaces_around_shift_operators = true 227 | ij_java_spaces_around_type_bounds_in_type_parameters = true 228 | ij_java_spaces_around_unary_operator = false 229 | ij_java_spaces_within_angle_brackets = false 230 | ij_java_spaces_within_annotation_parentheses = false 231 | ij_java_spaces_within_array_initializer_braces = false 232 | ij_java_spaces_within_braces = false 233 | ij_java_spaces_within_brackets = false 234 | ij_java_spaces_within_cast_parentheses = false 235 | ij_java_spaces_within_catch_parentheses = false 236 | ij_java_spaces_within_for_parentheses = false 237 | ij_java_spaces_within_if_parentheses = false 238 | ij_java_spaces_within_method_call_parentheses = false 239 | ij_java_spaces_within_method_parentheses = false 240 | ij_java_spaces_within_parentheses = false 241 | ij_java_spaces_within_record_header = false 242 | ij_java_spaces_within_switch_parentheses = false 243 | ij_java_spaces_within_synchronized_parentheses = false 244 | ij_java_spaces_within_try_parentheses = false 245 | ij_java_spaces_within_while_parentheses = false 246 | ij_java_special_else_if_treatment = true 247 | ij_java_subclass_name_suffix = Impl 248 | ij_java_ternary_operation_signs_on_next_line = false 249 | ij_java_ternary_operation_wrap = off 250 | ij_java_test_name_suffix = Test 251 | ij_java_throws_keyword_wrap = off 252 | ij_java_throws_list_wrap = off 253 | ij_java_use_external_annotations = false 254 | ij_java_use_fq_class_names = false 255 | ij_java_use_relative_indents = false 256 | ij_java_use_single_class_imports = true 257 | ij_java_variable_annotation_wrap = off 258 | ij_java_visibility = public 259 | ij_java_while_brace_force = never 260 | ij_java_while_on_new_line = false 261 | ij_java_wrap_comments = false 262 | ij_java_wrap_first_method_in_call_chain = false 263 | ij_java_wrap_long_lines = false 264 | 265 | [.editorconfig] 266 | ij_editorconfig_align_group_field_declarations = false 267 | ij_editorconfig_space_after_colon = false 268 | ij_editorconfig_space_after_comma = true 269 | ij_editorconfig_space_before_colon = false 270 | ij_editorconfig_space_before_comma = false 271 | ij_editorconfig_spaces_around_assignment_operators = true 272 | 273 | [{*.markdown,*.md}] 274 | ij_markdown_force_one_space_after_blockquote_symbol = true 275 | ij_markdown_force_one_space_after_header_symbol = true 276 | ij_markdown_force_one_space_after_list_bullet = true 277 | ij_markdown_force_one_space_between_words = true 278 | ij_markdown_keep_indents_on_empty_lines = false 279 | ij_markdown_max_lines_around_block_elements = 1 280 | ij_markdown_max_lines_around_header = 1 281 | ij_markdown_max_lines_between_paragraphs = 1 282 | ij_markdown_min_lines_around_block_elements = 1 283 | ij_markdown_min_lines_around_header = 1 284 | ij_markdown_min_lines_between_paragraphs = 1 285 | 286 | [{*.yml,*.yaml}] 287 | indent_size = 2 288 | -------------------------------------------------------------------------------- /src/main/java/dev/niels/sqlbackuprestore/action/Restore.java: -------------------------------------------------------------------------------- 1 | package dev.niels.sqlbackuprestore.action; 2 | 3 | import com.intellij.database.actions.RefreshModelAction; 4 | import com.intellij.database.model.DasObject; 5 | import com.intellij.notification.Notification; 6 | import com.intellij.notification.NotificationType; 7 | import com.intellij.notification.Notifications.Bus; 8 | import com.intellij.openapi.actionSystem.ActionUpdateThread; 9 | import com.intellij.openapi.actionSystem.AnActionEvent; 10 | import com.intellij.openapi.application.ApplicationManager; 11 | import com.intellij.openapi.project.DumbAwareAction; 12 | import com.intellij.openapi.project.Project; 13 | import com.intellij.openapi.ui.Messages; 14 | import dev.niels.sqlbackuprestore.AppSettingsState; 15 | import dev.niels.sqlbackuprestore.Constants; 16 | import dev.niels.sqlbackuprestore.query.Auditor.MessageType; 17 | import dev.niels.sqlbackuprestore.query.Client; 18 | import dev.niels.sqlbackuprestore.query.ProgressTask; 19 | import dev.niels.sqlbackuprestore.query.QueryHelper; 20 | import dev.niels.sqlbackuprestore.query.RemoteFileWithMeta; 21 | import dev.niels.sqlbackuprestore.query.RemoteFileWithMeta.BackupType; 22 | import dev.niels.sqlbackuprestore.ui.RestoreFilenamesDialog; 23 | import dev.niels.sqlbackuprestore.ui.RestoreFullPartialDialog; 24 | import dev.niels.sqlbackuprestore.ui.filedialog.FileDialog; 25 | import dev.niels.sqlbackuprestore.ui.filedialog.RemoteFile; 26 | import lombok.AllArgsConstructor; 27 | import lombok.Data; 28 | import lombok.extern.slf4j.Slf4j; 29 | import one.util.streamex.StreamEx; 30 | import org.apache.commons.lang3.ArrayUtils; 31 | import org.apache.commons.lang3.StringUtils; 32 | import org.jetbrains.annotations.NotNull; 33 | import org.jetbrains.annotations.Nullable; 34 | 35 | import java.io.FileInputStream; 36 | import java.io.FileOutputStream; 37 | import java.io.IOException; 38 | import java.lang.reflect.InvocationTargetException; 39 | import java.util.HashMap; 40 | import java.util.List; 41 | import java.util.Map; 42 | import java.util.Optional; 43 | import java.util.concurrent.ArrayBlockingQueue; 44 | import java.util.concurrent.CompletableFuture; 45 | import java.util.concurrent.ExecutionException; 46 | import java.util.function.BiConsumer; 47 | import java.util.function.Supplier; 48 | import java.util.stream.Collectors; 49 | import java.util.zip.GZIPInputStream; 50 | 51 | /** 52 | * Restore a database from a (remote) file. Cannot be a gzipped file. 53 | */ 54 | @Slf4j 55 | public class Restore extends DumbAwareAction { 56 | @Override 57 | public @NotNull ActionUpdateThread getActionUpdateThread() { 58 | return ActionUpdateThread.BGT; 59 | } 60 | 61 | @Override 62 | public void actionPerformed(@NotNull AnActionEvent e) { 63 | @SuppressWarnings("resource") 64 | var c = QueryHelper.client(e); 65 | c.setTitle("Restore database"); 66 | 67 | CompletableFuture.runAsync(() -> { 68 | c.setTitle("Restore database"); 69 | var target = QueryHelper.getDatabase(e).map(DasObject::getName); 70 | var files = invokeAndWait(() -> FileDialog.chooseFiles(null, e.getProject(), c, "Restore " + target.orElse("new database"))); 71 | if (ArrayUtils.isEmpty(files)) { 72 | return; 73 | } 74 | 75 | var database = target.orElseGet(() -> invokeAndWait(() -> promptDatabaseName(StringUtils.removeEnd(StringUtils.removeEnd(files[0].getName(), ".gzip"), ".bak")))); 76 | if (StringUtils.isBlank(database)) { 77 | return; 78 | } 79 | 80 | var toRestore = determineToRestore(e.getProject(), files, c); 81 | if (toRestore == null) { 82 | return; 83 | } 84 | 85 | c.setTitle("Restore " + database); 86 | try { 87 | checkDatabaseInUse(e.getProject(), c, database); 88 | } catch (Exception ex) { 89 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, Constants.ERROR, "Unable to determine database usage or close connections: " + ex.getMessage(), NotificationType.ERROR)); 90 | } 91 | 92 | c.open(); 93 | new ProgressTask(e.getProject(), "Restore backup", false, consumer -> { 94 | try { 95 | new RestoreHelper(c, database, toRestore, consumer).unzipIfNeeded() 96 | .restore() 97 | .thenRun(() -> hackedRefresh(e)) 98 | .thenRun(c::close).exceptionally(c::close) 99 | .get(); 100 | } catch (Exception ex) { 101 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, Constants.ERROR, ex.getMessage(), NotificationType.ERROR)); 102 | } 103 | }).queue(); 104 | }) 105 | .thenRun(c::close) 106 | .exceptionally(c::close); 107 | } 108 | 109 | /** 110 | * RefreshModelAction.actionPerformed is override only. Try to hide the call from the verifier. 111 | */ 112 | private void hackedRefresh(@NotNull AnActionEvent e) { 113 | try { 114 | var refreshAction = new RefreshModelAction(); 115 | var actionPerformed = RefreshModelAction.class.getMethod("actionPerformed", AnActionEvent.class); 116 | actionPerformed.invoke(refreshAction, e); 117 | } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) { 118 | log.error("Unable to refresh selected item"); 119 | } 120 | } 121 | 122 | private @Nullable RestoreAction determineToRestore(@Nullable Project project, RemoteFile[] files, Client c) { 123 | var withMeta = StreamEx.of(files) 124 | .map(RemoteFileWithMeta.factory(c)) 125 | .toList(); 126 | var fullsWithPartials = StreamEx.of(withMeta) 127 | .filter(RemoteFileWithMeta::isFull) 128 | .mapToEntry(full -> StreamEx.of(withMeta).filter(m -> m.isPartialOf(full)).toList()) 129 | .toMap(); 130 | 131 | if (fullsWithPartials.isEmpty()) { 132 | return null; 133 | } 134 | if (fullsWithPartials.size() == 1 && fullsWithPartials.values().iterator().next().isEmpty()) { 135 | return new RestoreAction(fullsWithPartials.keySet().iterator().next(), null); 136 | } 137 | 138 | return RestoreFullPartialDialog.choose(project, fullsWithPartials); 139 | } 140 | 141 | private void checkDatabaseInUse(Project project, Client c, String target) throws ExecutionException, InterruptedException { 142 | c.withRows(String.format(""" 143 | SELECT 144 | [Session ID] = s.session_id, 145 | [User Process] = CONVERT(CHAR(1), s.is_user_process), 146 | [Login] = s.login_name, 147 | [Application] = ISNULL(s.program_name, N''), 148 | [Open Transactions] = ISNULL(r.open_transaction_count,0), 149 | [Last Request Start Time] = s.last_request_start_time, 150 | [Host Name] = ISNULL(s.host_name, N''), 151 | [Net Address] = ISNULL(c.client_net_address, N'') 152 | FROM sys.dm_exec_sessions s 153 | LEFT OUTER JOIN sys.dm_exec_connections c ON (s.session_id = c.session_id) 154 | LEFT OUTER JOIN sys.dm_exec_requests r ON (s.session_id = r.session_id) 155 | LEFT OUTER JOIN sys.sysprocesses p ON (s.session_id = p.spid) 156 | where db_name(p.dbid) = '%s' 157 | ORDER BY s.session_id;""", target), (cs, rs) -> { 158 | }) 159 | .thenCompose(rows -> { 160 | if (!rows.isEmpty() && Messages.YES == invokeAndWait(() -> Messages.showYesNoDialog(project, 161 | String.format("There are %s sessions active on this database, do you want to close those?", rows.size()), 162 | "Close Connections?", 163 | Messages.getQuestionIcon()))) { 164 | 165 | CompletableFuture chain = CompletableFuture.completedFuture(null); 166 | for (Map row : rows) { 167 | chain = chain.thenRun(() -> c.execute("KILL " + row.get("Session ID"))); 168 | } 169 | return chain; 170 | } else { 171 | return CompletableFuture.completedFuture(null); 172 | } 173 | }).get(); 174 | } 175 | 176 | private String promptDatabaseName(String initial) { 177 | var name = Messages.showInputDialog("Create a new database from backup", "Database Name", null, initial, null); 178 | return StringUtils.stripToNull(name); 179 | } 180 | 181 | /** 182 | * Helper invokeAndWait method that returns the value from the supplier 183 | */ 184 | public T invokeAndWait(Supplier supplier) { 185 | var blocker = new ArrayBlockingQueue>(1); 186 | ApplicationManager.getApplication().invokeLater(() -> blocker.add(Optional.ofNullable(supplier.get()))); 187 | try { 188 | return blocker.take().orElse(null); 189 | } catch (InterruptedException e) { 190 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, Constants.ERROR, e.getMessage(), NotificationType.ERROR)); 191 | Thread.currentThread().interrupt(); 192 | throw new IllegalStateException(e); 193 | } 194 | } 195 | 196 | @AllArgsConstructor 197 | @Slf4j 198 | private static class RestoreHelper { 199 | private final Client connection; 200 | private final String target; 201 | private RestoreAction action; 202 | private final BiConsumer progressConsumer; 203 | private final Map uniqueNames = new HashMap<>(); 204 | 205 | public RestoreHelper unzipIfNeeded() { 206 | var files = action.getFiles().map(RemoteFileWithMeta::getFile).map(RemoteFile::getPath).toList(); 207 | for (var file : files) { 208 | if (file.toLowerCase().endsWith(".gzip")) { 209 | var unzipped = StringUtils.appendIfMissing(StringUtils.removeEndIgnoreCase(file, ".gzip"), ".bak"); 210 | try (var fis = new FileInputStream(file); var gzis = new GZIPInputStream(fis); var fos = new FileOutputStream(unzipped)) { 211 | byte[] buffer = new byte[1024]; 212 | int length; 213 | while ((length = gzis.read(buffer)) > 0) { 214 | fos.write(buffer, 0, length); 215 | } 216 | file = unzipped; 217 | } catch (IOException e) { 218 | log.warn("failed to unzip {}", file); 219 | } 220 | } 221 | } 222 | return this; 223 | } 224 | 225 | @Data 226 | @AllArgsConstructor 227 | private static class ObjectHolder { 228 | private T value; 229 | } 230 | 231 | public CompletableFuture restore() { 232 | var temp = new RestoreTemp(); 233 | var result = new ObjectHolder<>(CompletableFuture.completedFuture(null)); 234 | action.getFiles().forEach(file -> result.setValue( 235 | result.getValue().thenCompose(x -> connection.getResult("RESTORE FILELISTONLY FROM DISK = N'" + file.getFile().getPath() + "';")) 236 | .thenApply(temp::setFiles) 237 | .thenCompose(x -> determineTargetPath()) 238 | .thenApply(temp::setLocation) 239 | .thenAccept(this::defaultFileNames) 240 | .thenApply(v -> determineRestoreQuery(action, file, temp)) 241 | 242 | .thenCompose(sql -> connection.addWarningConsumer(this::progress).execute(sql)) 243 | .thenApply(x -> null) 244 | .exceptionally(e -> null) 245 | )); 246 | return result.getValue(); 247 | } 248 | 249 | private String determineRestoreQuery(RestoreAction action, RemoteFileWithMeta file, RestoreTemp temp) { 250 | if (action.getType(file) == BackupType.FULL) { 251 | if (AppSettingsState.getInstance().isAskForRestoreFileLocations()) { 252 | askForFileLocations(temp); 253 | } 254 | 255 | var recovery = action.partialBackup == null ? "" : "NORECOVERY, "; 256 | var moves = temp.files.stream().map(s -> String.format("MOVE N'%s' TO N'%s'", s.get("LogicalName"), s.get("RestoreAs"))).collect(Collectors.joining(", ")); 257 | return String.format("RESTORE DATABASE [%s] FROM DISK = N'%s' WITH file = 1, %s, %s NOUNLOAD, STATS = 5, REPLACE", target, file.getFile().getPath(), moves, recovery); 258 | } else { 259 | return String.format("RESTORE DATABASE [%s] FROM DISK = N'%s' WITH file = 1, NOUNLOAD, STATS = 5", target, file.getFile().getPath()); 260 | } 261 | } 262 | 263 | private void defaultFileNames(RestoreTemp temp) { 264 | temp.getFiles().forEach(v -> v.put("RestoreAs", determineFileName(temp.getLocation(), v))); 265 | } 266 | 267 | private void askForFileLocations(RestoreTemp files) { 268 | ApplicationManager.getApplication().invokeAndWait(() -> { 269 | if (!new RestoreFilenamesDialog(null, files).showAndGet()) { 270 | throw new RuntimeException("Restore cancelled"); 271 | } 272 | }); 273 | } 274 | 275 | private String determineFileName(String path, Map values) { 276 | var type = (String) values.get("Type"); 277 | var ext = StringUtils.equalsIgnoreCase(type, "L") ? "_log.ldf" : ".mdf"; 278 | return StringUtils.stripEnd(path, "/\\") + '\\' + uniqueName(target, ext); 279 | } 280 | 281 | private String uniqueName(String target, String ext) { 282 | int count = uniqueNames.compute(target + ext, (k, v) -> v == null ? 0 : v + 1); 283 | if (count == 0) { 284 | return target + ext; 285 | } 286 | return target + "_" + count + ext; 287 | } 288 | 289 | private CompletableFuture determineTargetPath() { 290 | var max = "case when CHARINDEX('\\',REVERSE(physical_name)) > CHARINDEX('/',REVERSE(physical_name)) then CHARINDEX('\\',REVERSE(physical_name)) else CHARINDEX('/',REVERSE(physical_name)) end"; 291 | var path = "LEFT(physical_name,LEN(physical_name)-(" + max + ")+1)"; 292 | var pathQuery = "SELECT top 1 " + path + " path, count(*)\n" + 293 | " FROM sys.master_files mf\n" + 294 | " INNER JOIN sys.[databases] d ON mf.[database_id] = d.[database_id] \n" + 295 | "group by " + path + "\n" + 296 | "order by count(*) desc;"; 297 | 298 | return connection.getSingle(pathQuery, "path"); 299 | } 300 | 301 | private void progress(MessageType messageType, String warning) { 302 | if (messageType == MessageType.ERROR) { 303 | Bus.notify(new Notification(Constants.NOTIFICATION_GROUP, Constants.ERROR, warning, NotificationType.ERROR)); 304 | } 305 | progressConsumer.accept(messageType, warning); 306 | } 307 | } 308 | 309 | @Data 310 | public static class RestoreTemp { 311 | private List> files; 312 | private String location; 313 | } 314 | 315 | public record RestoreAction(@NotNull RemoteFileWithMeta fullBackup, @Nullable RemoteFileWithMeta partialBackup) { 316 | public StreamEx getFiles() { 317 | return StreamEx.of(fullBackup, partialBackup).nonNull(); 318 | } 319 | 320 | public BackupType getType(RemoteFileWithMeta bak) { 321 | return fullBackup == bak ? BackupType.FULL : partialBackup == bak ? BackupType.PARTIAL : BackupType.UNSUPPORTED; 322 | } 323 | } 324 | } 325 | --------------------------------------------------------------------------------