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 (8)

Collapse
 
gautamtalksdev profile image
Gautam K •

Starting from the fact that text-to-SQL safety ends up relying on keyword blocklists is the right place to begin, because a blocklist is a guess about every query a model might write. For the shop owner asking how many orders ship tomorrow, the question maps to a small set of known queries anyway. Are you planning to have the model pick from predefined query templates with parameters, or build a structured intent that the engine turns into SQL itself?

Collapse
 
seryllns_ profile image
Seryl Lns •

Thanks Gautam! I'm going with the second approach, but with a bit more structure around it.

The LLM outputs a structured JSON query, not SQL. DataTeller then validates it against the declared schema and compiles it into an ActiveRecord query, which eventually becomes SQL.

So the model decides what data it wants, but the engine controls how that request is executed. Tenant isolation, permissions and execution constraints are enforced by the engine, not left to the model.

I'll get into the details of the compiler in one of the upcoming articles !!!

Collapse
 
gautamtalksdev profile image
Gautam K •

A structured JSON query checked against the declared schema and then compiled is the split I'd want too: the model chooses what, the engine chooses how and who can see it.

Where do aggregates and joins sit in that JSON, for example a count grouped by a field on a related table? That seems like the place where a schema check gets hardest.

Thread Thread
 
seryllns_ profile image
Seryl Lns •

Metrics are declared in the DSL (count, sum, avg, etc...) with derived metrics such as ratios defined by the developer.

Dimensions can reference associated models through Rails association paths.

The JSON only references declared metrics and dimensions (e.g. count grouped by customer.country)
The engine validates fields, association paths, and permissions, then builds the joins and applies tenant filters.

Thread Thread
 
gautamtalksdev profile image
Gautam K •

Declared metrics plus association paths the engine validates means the model can only ask for joins the developer already allowed, which is the right boundary. The part I'd test hardest is the tenant filter on every joined model, not only the root table, since a join is where rows from another tenant could slip in. Is that filter applied per joined model today? Looking forward to the compiler post.

Collapse
 
analista_83 profile image
Sammi De Blas •

Constraining the model to a prepared statement template and letting it choose only the parameters is the same pattern as a whitelist in a reverse proxy. The part most writeups skip is the latency of the validation layer. How many extra round trips per query did the guardrail add in your tests?

Collapse
 
seryllns_ profile image
Seryl Lns •

Thanks for bringing this up, that's actually a really interesting question !

In DataTeller, validation doesn't require an additional LLM call or a separate database round trip. The JSON is validated locally against the declared schema and permissions, then compiled into an ActiveRecord query

However, I haven't measured the overhead of each stage independently yet. The PostgreSQL read-only role and RLS policies also introduce some execution overhead that I haven't quantified

I'd definitely like to benchmark these layers separately to understand the actual cost of the security guarantees

Thanks again for pointing this out !

Collapse
 
analista_83 profile image
Sammi De Blas •

Measure the local schema check and the database policy separately. The template check is cheap, the RLS policy is not.