DEV Community

Cover image for Building DataTeller #0: Why I Don't Let an LLM Write SQL
Seryl Lns
Seryl Lns

Posted on

Building DataTeller #0: Why I Don't Let an LLM Write SQL

I'm building a Rails engine that lets SaaS customers ask questions about their own data in plain language. I don't want an LLM writing SQL or having direct access to my database. Not in a production SaaS application. This is why, and what this series will build, test and measure.

Context

My last series, DataPorter, was about engineering a gem: a DSL, an orchestrator, generators, a UI. This one has a second goal. I want to learn LLM integration and retrieval (RAG) properly, at a low level, with numbers instead of vibes.

So here is the deal. I'm building a real gem, in the open, and the series follows the order in which I solve the problems. Each part covers code that exists and is tested, every decision comes with the options I turned down, and once the LLM is in, the numbers come from evaluation runs, not from impressions.

The problem

Picture a SaaS that sells order management to online shops. A shop owner opens the app and wonders:

"How many orders do I have to ship tomorrow?"

Today, that owner needs a dashboard someone built, or a support ticket. The obvious idea in 2026 is to put an LLM in front of the database: let it write the SQL, run it, show the result. It's called text-to-SQL, it demos beautifully, and for an internal analyst it can be great.

Giving an LLM access to a database sounds convenient at first. But the moment you put this in front of actual customers, things get more complicated :

  • The LLM writes raw SQL. Safety then relies on keyword blocklists and read-only transactions. That is a defence you hope works.
  • Tenant isolation becomes the LLM's job. "Only look at account 42" is an instruction in a prompt. One prompt injection away from leaking another customer's data.
  • Your schema, and often your results, go to an AI provider.

If you run a multi-tenant app, the second point alone should make you pause

Text-to-SQL What I want
What the LLM produces Raw SQL A structured query, validated against what the developer declared
Tenant isolation Asked of the model Applied by the engine, outside the model's reach
What leaves your app Schema, sometimes rows The question and declared labels, never result rows
Who it is for Analysts and admins The SaaS's own customers

What we're building

Here is where it's going. This is a mockup: a fake shop, fake data and scripted answers, made to show the idea. It only shows what is planned: a number or a table, how the question was understood, a clarifying question with fixed choices, a refusal. No chart, and no sentence written by the LLM.

Mockup of DataTeller: a shop owner asks questions about their own data from a command palette

DataTeller is a mountable Rails engine. The developer declares what can be asked about: datasets, metrics, dimensions, segments, with business names and synonyms. The LLM never sees the database. It sees the question and the declared vocabulary, and it fills in a structured query:

{
  "version": 1,
  "intent": "answer",
  "dataset": "orders",
  "metric": "count",
  "period": { "dimension": "ship_on", "range": { "relative": "tomorrow" } },
  "filters": [ { "segment": "paid" } ]
}
Enter fullscreen mode Exit fullscreen mode

That JSON is validated against the declarations, then compiled by the engine into ActiveRecord, with the tenant filter added by the compiler. This is the SQL it runs on the dummy app :

SELECT COUNT(*) AS c0 FROM "orders"
WHERE "orders"."status" = $1                                -- the "paid" segment, as the developer declared it
  AND "orders"."ship_on" >= $2 AND "orders"."ship_on" < $3  -- "tomorrow", in the tenant's time zone
  AND "orders"."account_id" = $4                            -- the tenant filter, added by the compiler
Enter fullscreen mode Exit fullscreen mode

Every value is a bound parameter, and every column name comes from the declarations.
The tenant filter is added by the engine, independently of the LLM-generated query.
The compiler is responsible for preserving that boundary. How to guarantee this, and how to test it against hostile inputs, is one of the central challenges of this project.

The whole pipeline:

Pipeline schema

Step 6 is worth a remark: the explanation shown to the user is generated from the validated query, not written by the LLM. No sentence the customer reads comes from the model. That is a deliberate choice, and I'll defend it in a later part.

Why not use what exists?

There are already tools solving parts of this problem.

Blazer provides BI capabilities for Rails, with AI extensions such as SQL generation. QueryLens offers a Rails engine built around natural-language queries and SQL execution. Outside Rails, Cube provides a more comprehensive semantic layer with governed metrics, dimensions and access policies.

These are valid approaches. But DataTeller starts from a different assumption: the person asking questions is an end customer of the SaaS, not an administrator with database access.

I don't want an LLM exploring the database schema and generating SQL. I want developers to explicitly define what users can query, while the engine enforces authorization and tenant isolation independently of the model.

That's the focus of DataTeller:

  • No text-to-SQL. The LLM produces structured queries, never executable SQL.
  • Explicit data exposure through developer-defined datasets, metrics and dimensions.
  • Host application authorization, evaluated server-side.
  • Tenant isolation enforced during compilation.
  • Native ActiveRecord execution.

I'm not trying to reinvent natural-language analytics. I'm exploring a safer, Rails-native way to expose application data to end users.

Three decisions made up front

Decision Chosen Rejected Why
LLM output A validated structured query Raw SQL The engine controls the translation to ActiveRecord
LLM tooling Raw HTTP, my own retrieval An orchestration framework The point is to understand each layer
What is exposed An explicit, versioned config Inference at runtime A new column becomes queryable only through a visible diff

What this series covers

Roughly in this order, the order in which I solve the problems:

  1. Scaffolding the engine and a multi-tenant dummy app
  2. The query format: six design decisions
  3. The declaration DSL
  4. Validator, compiler, and why multi-tenant isolation is not an LLM problem
  5. Execution guards, permissions, aggregate disclosure
  6. How I test AI data access security
  7. An evaluation suite, written before the first prompt
  8. Adding the LLM, and a measured baseline
  9. RAG: vectors versus hybrid retrieval, with numbers
  10. And then clarification, explanations, prompt injection, publishing

From the evaluation part onward, the articles carry numbers. Before that, the proof is in the specs

What I don't know yet

  • Expressiveness is limited on purpose: one metric per query, filters combined with AND only, no sub-queries. Some real questions won't fit. The evaluation measures how often, and the articles will show it.
  • "No result rows go to the provider" is not "no data goes to the provider". The question itself can contain a customer's name. I'll document exactly what leaves the app.
  • Security claims need evidence. That is why a test kit is part of the plan, not an afterthought.

Recap

  • Text-to-SQL is fine for analysts and worrying in front of customers: raw SQL, isolation delegated to a prompt, schema sent out.
  • DataTeller has the LLM fill a structured query that the engine validates and compiles, with the tenant scope injected by the engine.
  • This series builds it in the open, and measures what can be measured.

I don't know yet how far I can take this approach, but that's part of the fun. I'll document the decisions, mistakes and trade-offs along the way.

Next up

In part 1, we scaffold the engine: a mountable gem, a dummy app with several tenants and fake data, and Postgres with pgvector, before a single line of AI.


This is part 0 of the **Building DataTeller* series, where I document the process of building a secure AI data access engine for Rails.*

The source code will become publicly available with Part 1.

Next: Part 1 — [Coming soon]

Source code: github.com/SerylLns/data_teller (repository currently private)

Top comments (0)