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/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.
- - Read-only by design. An AST guard checks every statement before it runs, on every
- engine. On Postgres, MySQL, Oracle and SQLite the session itself is opened read-only as a
- second floor. MongoDB has no read-only session to open, so there the guard is the only
- one, working from an allowlist rather than a blocklist; the same is true of DuckDB when
- it is holding data files, because loading them needs to create views.
+ - Read-only by design. An AST guard checks every statement before it runs, on
+ every engine, and on Postgres, MySQL, Oracle and SQLite the session itself is opened
+ read-only as a second floor. (MongoDB and file-backed DuckDB have no read-only session
+ to open, so there the guard is the only floor, working from an allowlist rather than a
+ blocklist.)
- Asking for a change is never a change. Ask for an INSERT, UPDATE, DELETE or a
schema change and you get the statement written out to run yourself, never executed.
- Six databases. Postgres, MySQL, SQLite, DuckDB, Oracle and MongoDB.
- Or no database at all. Point it at CSV, TSV, TXT, JSON, NDJSON, Parquet, Excel or
.sql files and query them as tables, joining across files. No server to install.
+ - Already set up in DataGrip or IDEA Ultimate? AskSQL reuses the data sources you
+ configured in Database Tools and SQL, so you do not enter the same connection details
+ twice. In the free IDEs it connects over JDBC on its own.
- Questions about the schema, not just the rows. "How are these tables related?" is
answered in plain language from your schema, and a query it suggests can be copied
straight out.
+ - Results you can read. A table, and a chart when the shape suits one: a bar chart
+ for a label and a number, a line for a date. Export to CSV, or open the full result in
+ an editor.
- Teach it your vocabulary. A business glossary maps terms your schema does not
define, which is also how you tell it that
total_cents is cents.
- Bring your own model. OpenAI, Anthropic, Google Gemini, Groq, NVIDIA, or any
OpenAI-compatible endpoint including Azure AI Foundry. Ollama and LM Studio run on your
machine with no API key.
- - Private by default. Zero telemetry. Only your schema reaches the model, never the
- rows in your tables; secrets live in your OS keychain.
+ - Private by default. Zero telemetry. Only your schema reaches the model, never
+ the rows in your tables, unless you switch on sample column values yourself. Secrets
+ live in your OS keychain.
+ - Costs nothing until you ask. Installed and idle, it loads no classes, adds
+ nothing to IDE startup, and sits at 0.2% CPU. Connecting to a wide schema takes three
+ catalog queries rather than one per table.
- Every JetBrains IDE. IntelliJ IDEA, DataGrip, PyCharm, WebStorm, GoLand, PhpStorm,
Rider, CLion, RubyMine, RustRover and Android Studio (2024.2 and newer).
+ AskSQL reads. It does not write, migrate or manage schemas, and it does not replace your
+ IDE's database tools.
Getting started
- Open the AskSQL tool window on the right.
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)
+ }
+ }
+}