From 0dc65dbbaee9b5d05b95afb792bd4aba4e70edb1 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Fri, 7 Aug 2026 18:08:40 +0800 Subject: [PATCH 1/2] Bring the plugin description in line with what the plugin does The Marketplace page was updated by hand; this is the same text, so the next release republishes it rather than reverting to the old copy. Adds what was missing: reusing a DataGrip or IDEA Ultimate data source, charts and CSV export, and what the plugin costs while idle. Corrects the privacy line, which said rows never reach the model without noting the sample-values setting that can send them, and moves the MongoDB and DuckDB caveat into parentheses so the read-only claim reads first. --- .../src/main/resources/META-INF/plugin.xml | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/jetbrains/src/main/resources/META-INF/plugin.xml b/packages/jetbrains/src/main/resources/META-INF/plugin.xml index 743b0c0..25b9692 100644 --- a/packages/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/packages/jetbrains/src/main/resources/META-INF/plugin.xml @@ -4,32 +4,45 @@ Rahul Mahadik AI database chat inside your IDE. Ask a question in plain language, review the SQL it - writes, approve it, and get results.

+

AI database chat inside your IDE, with your rows never leaving your machine. Ask a + question in plain language, review the SQL the AI writes, approve it, and get results. Run + the AI model locally through Ollama or LM Studio and nothing leaves at all.

+

AskSQL reads. It does not write, migrate or manage schemas, and it does not replace your + IDE's database tools.

Getting started

  1. Open the AskSQL tool window on the right.
  2. From c1924bdaeba9d246c3b8a376ec219ea38159cfc2 Mon Sep 17 00:00:00 2001 From: rahulmahadik Date: Sun, 9 Aug 2026 01:25:00 +0800 Subject: [PATCH 2/2] Keep connections and settings across an IDE restart IntelliJ's xmlb serializer collects only non-final fields. Every field in AskSqlAppState, AskSqlProjectState and ConnectionState was a Kotlin val, so each one was dropped on save with no error: connections, provider, model, base URL, row caps, custom instructions and the glossary were written empty and read back empty on the next start. requireApproval was part of the same state, so switching it on bought an approval step that was gone after the next restart. The round trip is covered three ways: through the state object, through XmlSerializer, and by a reflection guard that fails on any final field in a state class. loadState(getState()) alone proves nothing here, since it hands back the same instance without touching the serializer. --- packages/jetbrains/CHANGELOG.md | 10 +++ packages/jetbrains/gradle.properties | 2 +- .../asksql/ide/settings/AskSqlAppSettings.kt | 26 +++--- .../ide/settings/AskSqlProjectSettings.kt | 4 +- .../asksql/ide/settings/ConnectionState.kt | 22 ++--- .../ide/settings/ConnectionPersistenceTest.kt | 87 +++++++++++++++++++ .../settings/ConnectionXmlPersistenceTest.kt | 72 +++++++++++++++ .../settings/SettingsXmlPersistenceTest.kt | 66 ++++++++++++++ 8 files changed, 262 insertions(+), 27 deletions(-) create mode 100644 packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionPersistenceTest.kt create mode 100644 packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionXmlPersistenceTest.kt create mode 100644 packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/SettingsXmlPersistenceTest.kt diff --git a/packages/jetbrains/CHANGELOG.md b/packages/jetbrains/CHANGELOG.md index f901181..b4f96cb 100644 --- a/packages/jetbrains/CHANGELOG.md +++ b/packages/jetbrains/CHANGELOG.md @@ -5,6 +5,16 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.4.2] - 2026-08-09 + +### Fixed +- **Your connections and settings survive closing the IDE.** Every field in the plugin's saved state + was declared in a way IntelliJ's serializer silently drops, so connections, provider, model, base + URL, row caps, custom instructions and the glossary were all written as empty and came back empty + on the next start. Nothing warned; the settings simply reverted every time. +- **"Require explicit approval before running generated SQL" stays on.** It was part of the same + state, so a user who switched it on lost the approval step at the next restart without being told. + ## [0.4.1] - 2026-08-06 ### Fixed diff --git a/packages/jetbrains/gradle.properties b/packages/jetbrains/gradle.properties index 4b7cfd2..8f85a9a 100644 --- a/packages/jetbrains/gradle.properties +++ b/packages/jetbrains/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.rahulmahadik.asksql pluginName = AskSQL -pluginVersion = 0.4.1 +pluginVersion = 0.4.2 # IntelliJ Platform target used to COMPILE and RUN the sandbox. Broad # compatibility is governed by pluginSinceBuild/pluginUntilBuild in diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/AskSqlAppSettings.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/AskSqlAppSettings.kt index 1d67228..6f9c38b 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/AskSqlAppSettings.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/AskSqlAppSettings.kt @@ -10,24 +10,24 @@ import com.intellij.openapi.components.service private const val CURRENT_STATE_VERSION = 1 data class AskSqlAppState( - @JvmField val stateVersion: Int = CURRENT_STATE_VERSION, - @JvmField val provider: String = "", - @JvmField val model: String = "", - @JvmField val baseUrl: String? = null, - @JvmField val maxRows: Int = 100, + @JvmField var stateVersion: Int = CURRENT_STATE_VERSION, + @JvmField var provider: String = "", + @JvmField var model: String = "", + @JvmField var baseUrl: String? = null, + @JvmField var maxRows: Int = 100, /** Token budget for the schema sent to the model (estimate at ~4 chars/token). Higher fits more tables for complex joins; lower keeps prompts small for limited-context models. */ - @JvmField val maxSchemaTokens: Int = 5000, + @JvmField var maxSchemaTokens: Int = 5000, /** Send a few example values per field to the model. Off by default: only the schema leaves the machine. */ - @JvmField val allowDataInPrompt: Boolean = false, - @JvmField val requireApproval: Boolean = false, + @JvmField var allowDataInPrompt: Boolean = false, + @JvmField var requireApproval: Boolean = false, /** Auto-generate a plain-language description of each answer (one extra model call per query); the "Explain" button also produces it on demand. */ - @JvmField val explainAutomatically: Boolean = true, + @JvmField var explainAutomatically: Boolean = true, /** When a question can't become SQL, answer it in prose from the schema instead of erroring; a write request comes back as a statement to run yourself, never executed. */ - @JvmField val answerSchemaQuestions: Boolean = true, - @JvmField val connections: List = emptyList(), + @JvmField var answerSchemaQuestions: Boolean = true, + @JvmField var connections: List = emptyList(), /** Appended verbatim after the default system-prompt rules (see [com.rahulmahadik.asksql.ide.engine.Prompts.buildSqlSystem]). */ - @JvmField val customInstructions: String = "", - @JvmField val glossary: String = "", + @JvmField var customInstructions: String = "", + @JvmField var glossary: String = "", ) /** Application-scoped settings: AI provider/model/key selection and global engine defaults, held per machine (`RoamingType.DISABLED`). */ diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/AskSqlProjectSettings.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/AskSqlProjectSettings.kt index b2a40aa..8999c99 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/AskSqlProjectSettings.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/AskSqlProjectSettings.kt @@ -10,8 +10,8 @@ private const val CURRENT_STATE_VERSION = 1 /** Project-scoped connection descriptors only, never a password or API key (see [com.rahulmahadik.asksql.ide.settings.AskSqlSecrets]). */ data class AskSqlProjectState( - @JvmField val stateVersion: Int = CURRENT_STATE_VERSION, - @JvmField val connections: List = emptyList(), + @JvmField var stateVersion: Int = CURRENT_STATE_VERSION, + @JvmField var connections: List = emptyList(), ) @Service(Service.Level.PROJECT) diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionState.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionState.kt index 7b96e1b..b6feee8 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionState.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionState.kt @@ -7,18 +7,18 @@ import com.rahulmahadik.asksql.ide.model.EngineKind /** The persisted (XML-serializable) shape of a connection, kept separate from [ConnectionDescriptor]. Never carries a password. */ data class ConnectionState( - @JvmField val id: String = "", - @JvmField val name: String = "", - @JvmField val engine: String = "", - @JvmField val host: String? = null, - @JvmField val port: Int? = null, - @JvmField val database: String? = null, - @JvmField val user: String? = null, - @JvmField val filePath: String? = null, - @JvmField val connectionString: String? = null, - @JvmField val isSample: Boolean = false, + @JvmField var id: String = "", + @JvmField var name: String = "", + @JvmField var engine: String = "", + @JvmField var host: String? = null, + @JvmField var port: Int? = null, + @JvmField var database: String? = null, + @JvmField var user: String? = null, + @JvmField var filePath: String? = null, + @JvmField var connectionString: String? = null, + @JvmField var isSample: Boolean = false, /** [SslMode.name]; null falls back to [SslMode.TRUST]. */ - @JvmField val sslMode: String? = null, + @JvmField var sslMode: String? = null, ) fun ConnectionState.toDescriptor(scope: ConnectionScope): ConnectionDescriptor = ConnectionDescriptor( diff --git a/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionPersistenceTest.kt b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionPersistenceTest.kt new file mode 100644 index 0000000..97ac78b --- /dev/null +++ b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionPersistenceTest.kt @@ -0,0 +1,87 @@ +package com.rahulmahadik.asksql.ide.settings + +import com.rahulmahadik.asksql.ide.db.ConnectionScope +import com.rahulmahadik.asksql.ide.model.EngineKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +/** Connections must survive closing the IDE. */ +class ConnectionPersistenceTest { + + private fun sample(id: String) = ConnectionState( + id = id, + name = "Prod $id", + engine = "postgres", + host = "db.example", + port = 5432, + database = "app", + user = "reader", + sslMode = "VERIFY", + ) + + @Test fun `state written to project settings is returned by getState`() { + val settings = AskSqlProjectSettings() + settings.connections = listOf(sample("a"), sample("b")) + + val persisted = settings.state + assertEquals(2, persisted.connections.size) + assertEquals("a", persisted.connections[0].id) + assertEquals("Prod b", persisted.connections[1].name) + } + + @Test fun `a fresh instance loading that state has the connections back`() { + val original = AskSqlProjectSettings() + original.connections = listOf(sample("a"), sample("b")) + + // What the platform does on the next IDE start: construct, then hand back the stored state. + val reopened = AskSqlProjectSettings() + reopened.loadState(original.state) + + assertEquals(2, reopened.connections.size) + assertEquals(listOf("a", "b"), reopened.connections.map { it.id }) + } + + @Test fun `every field a connection needs survives the round trip`() { + val original = AskSqlProjectSettings() + original.connections = listOf(sample("a")) + + val reopened = AskSqlProjectSettings() + reopened.loadState(original.state) + val back = reopened.connections.single() + + assertEquals("a", back.id) + assertEquals("Prod a", back.name) + assertEquals("postgres", back.engine) + assertEquals("db.example", back.host) + assertEquals(5432, back.port) + assertEquals("app", back.database) + assertEquals("reader", back.user) + assertEquals("VERIFY", back.sslMode) + } + + @Test fun `a restored state converts back into a usable descriptor`() { + val original = AskSqlProjectSettings() + original.connections = listOf(sample("a")) + + val reopened = AskSqlProjectSettings() + reopened.loadState(original.state) + val descriptor = reopened.connections.single().toDescriptor(ConnectionScope.PROJECT) + + assertNotNull(descriptor) + assertEquals(EngineKind.POSTGRES, descriptor.engine) + assertEquals("db.example", descriptor.host) + assertEquals(ConnectionScope.PROJECT, descriptor.scope) + } + + @Test fun `application scoped connections persist too`() { + val app = AskSqlAppSettings() + app.connections = listOf(sample("global")) + + val reopened = AskSqlAppSettings() + reopened.loadState(app.state) + + assertEquals(1, reopened.connections.size) + assertEquals("global", reopened.connections.single().id) + } +} diff --git a/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionXmlPersistenceTest.kt b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionXmlPersistenceTest.kt new file mode 100644 index 0000000..a0349ab --- /dev/null +++ b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/ConnectionXmlPersistenceTest.kt @@ -0,0 +1,72 @@ +package com.rahulmahadik.asksql.ide.settings + +import com.intellij.util.xmlb.XmlSerializer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +/** + * Restarting the IDE round-trips state through XML on disk. An in-memory `loadState(getState())` + * never touches the serializer, so it cannot catch a field that fails to write or read back. + */ +class ConnectionXmlPersistenceTest { + + private val sample = ConnectionState( + id = "c1", + name = "Prod", + engine = "postgres", + host = "db.example", + port = 5432, + database = "app", + user = "reader", + sslMode = "VERIFY", + ) + + private fun roundTrip(state: AskSqlProjectState): AskSqlProjectState { + val element = XmlSerializer.serialize(state) + return XmlSerializer.deserialize(element, AskSqlProjectState::class.java) + } + + @Test fun `connections survive a serialize and deserialize cycle`() { + val back = roundTrip(AskSqlProjectState(connections = listOf(sample))) + + assertEquals(1, back.connections.size) + assertEquals("c1", back.connections.single().id) + } + + @Test fun `every connection field survives the XML cycle`() { + val back = roundTrip(AskSqlProjectState(connections = listOf(sample))).connections.single() + + assertEquals("c1", back.id) + assertEquals("Prod", back.name) + assertEquals("postgres", back.engine) + assertEquals("db.example", back.host) + assertEquals(5432, back.port) + assertEquals("app", back.database) + assertEquals("reader", back.user) + assertEquals("VERIFY", back.sslMode) + } + + @Test fun `a file backed connection survives the XML cycle`() { + val duck = ConnectionState(id = "d1", name = "Local", engine = "duckdb", filePath = "/tmp/a.duckdb") + val back = roundTrip(AskSqlProjectState(connections = listOf(duck))).connections.single() + + assertEquals("/tmp/a.duckdb", back.filePath) + assertEquals("duckdb", back.engine) + } + + @Test fun `several connections keep their order and identity`() { + val many = (1..5).map { sample.copy(id = "c$it", name = "Conn $it") } + val back = roundTrip(AskSqlProjectState(connections = many)) + + assertEquals((1..5).map { "c$it" }, back.connections.map { it.id }) + } + + @Test fun `the serialized XML actually contains the connection`() { + val xml = XmlSerializer.serialize(AskSqlProjectState(connections = listOf(sample))) + val text = com.intellij.openapi.util.JDOMUtil.write(xml) + + assertNotNull(text) + assert(text.contains("db.example")) { "connection host was not written to XML:\n$text" } + } +} diff --git a/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/SettingsXmlPersistenceTest.kt b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/SettingsXmlPersistenceTest.kt new file mode 100644 index 0000000..531cf0d --- /dev/null +++ b/packages/jetbrains/src/test/kotlin/com/rahulmahadik/asksql/ide/settings/SettingsXmlPersistenceTest.kt @@ -0,0 +1,66 @@ +package com.rahulmahadik.asksql.ide.settings + +import com.intellij.util.xmlb.XmlSerializer +import java.lang.reflect.Modifier +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Everything the user configures has to come back after a restart, not just connections. */ +class SettingsXmlPersistenceTest { + + private fun roundTrip(state: AskSqlAppState): AskSqlAppState = + XmlSerializer.deserialize(XmlSerializer.serialize(state), AskSqlAppState::class.java) + + @Test fun `provider and model survive a restart`() { + val back = roundTrip(AskSqlAppState(provider = "ollama", model = "qwen2.5-coder:7b", baseUrl = "http://localhost:11434")) + + assertEquals("ollama", back.provider) + assertEquals("qwen2.5-coder:7b", back.model) + assertEquals("http://localhost:11434", back.baseUrl) + } + + @Test fun `numeric limits survive a restart`() { + val back = roundTrip(AskSqlAppState(maxRows = 250, maxSchemaTokens = 9000)) + + assertEquals(250, back.maxRows) + assertEquals(9000, back.maxSchemaTokens) + } + + /** Reverting to the default here fails open: queries run without the approval the user asked for. */ + @Test fun `requireApproval stays on across a restart`() { + assertTrue(roundTrip(AskSqlAppState(requireApproval = true)).requireApproval) + } + + @Test fun `the remaining toggles survive in both directions`() { + val on = roundTrip(AskSqlAppState(allowDataInPrompt = true, explainAutomatically = true, answerSchemaQuestions = true)) + assertTrue(on.allowDataInPrompt) + assertTrue(on.explainAutomatically) + assertTrue(on.answerSchemaQuestions) + + val off = roundTrip(AskSqlAppState(allowDataInPrompt = false, explainAutomatically = false, answerSchemaQuestions = false)) + assertFalse(off.allowDataInPrompt) + assertFalse(off.explainAutomatically) + assertFalse(off.answerSchemaQuestions) + } + + @Test fun `custom instructions and glossary survive a restart`() { + val back = roundTrip(AskSqlAppState(customInstructions = "prefer CTEs", glossary = "ARR = annual recurring revenue")) + + assertEquals("prefer CTEs", back.customInstructions) + assertEquals("ARR = annual recurring revenue", back.glossary) + } + + /** xmlb collects only non-final fields, so a `val` in a state class is dropped on save with no error. */ + @Test fun `no persisted state field is final`() { + for (cls in listOf(AskSqlAppState::class.java, AskSqlProjectState::class.java, ConnectionState::class.java)) { + val finals = cls.declaredFields + .filterNot { Modifier.isStatic(it.modifiers) } + .filter { Modifier.isFinal(it.modifiers) } + .map { it.name } + + assertEquals("${cls.simpleName} has final fields, which xmlb drops on save: $finals", emptyList(), finals) + } + } +}