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.
- - 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.
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)
+ }
+ }
+}