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

  1. Open the AskSQL tool window on the right.
  2. 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) + } + } +}