Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/jetbrains/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/jetbrains/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionState> = emptyList(),
@JvmField var answerSchemaQuestions: Boolean = true,
@JvmField var connections: List<ConnectionState> = 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`). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionState> = emptyList(),
@JvmField var stateVersion: Int = CURRENT_STATE_VERSION,
@JvmField var connections: List<ConnectionState> = emptyList(),
)

@Service(Service.Level.PROJECT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
31 changes: 22 additions & 9 deletions packages/jetbrains/src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,45 @@
<vendor email="rahultkiet@gmail.com" url="https://github.com/rahulmahadik/AskSQL">Rahul Mahadik</vendor>

<description><![CDATA[
<p>AI database chat inside your IDE. Ask a question in plain language, review the SQL it
writes, approve it, and get results.</p>
<p>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.</p>
<ul>
<li><b>Read-only by design.</b> 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.</li>
<li><b>Read-only by design.</b> 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.)</li>
<li><b>Asking for a change is never a change.</b> Ask for an INSERT, UPDATE, DELETE or a
schema change and you get the statement written out to run yourself, never executed.</li>
<li><b>Six databases.</b> Postgres, MySQL, SQLite, DuckDB, Oracle and MongoDB.</li>
<li><b>Or no database at all.</b> 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.</li>
<li><b>Already set up in DataGrip or IDEA Ultimate?</b> 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.</li>
<li><b>Questions about the schema, not just the rows.</b> "How are these tables related?" is
answered in plain language from your schema, and a query it suggests can be copied
straight out.</li>
<li><b>Results you can read.</b> 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.</li>
<li><b>Teach it your vocabulary.</b> A business glossary maps terms your schema does not
define, which is also how you tell it that <code>total_cents</code> is cents.</li>
<li><b>Bring your own model.</b> 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.</li>
<li><b>Private by default.</b> Zero telemetry. Only your schema reaches the model, never the
rows in your tables; secrets live in your OS keychain.</li>
<li><b>Private by default.</b> 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.</li>
<li><b>Costs nothing until you ask.</b> 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.</li>
<li><b>Every JetBrains IDE.</b> IntelliJ IDEA, DataGrip, PyCharm, WebStorm, GoLand, PhpStorm,
Rider, CLion, RubyMine, RustRover and Android Studio (2024.2 and newer).</li>
</ul>
<p>AskSQL reads. It does not write, migrate or manage schemas, and it does not replace your
IDE's database tools.</p>
<p><b>Getting started</b></p>
<ol>
<li>Open the <b>AskSQL</b> tool window on the right.</li>
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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" }
}
}
Original file line number Diff line number Diff line change
@@ -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<String>(), finals)
}
}
}