Skip to content

Developer-Owned SQL

FraiseQL is database-first. You write the SQL views that power your GraphQL API. This is a deliberate choice — and it’s the reason FraiseQL can guarantee single-query execution, zero N+1 problems, and predictable performance.

Most GraphQL frameworks generate SQL behind the scenes. You write resolvers or schema definitions, and something you can’t see produces queries you can’t easily debug. When performance degrades, you’re reverse-engineering an abstraction layer.

FraiseQL takes the opposite approach: you write the SQL. The view you write is the query that runs. There’s nothing hidden.

This gives you:

  • Predictable performance — you see the query plan, you understand the cost
  • Full database power — CTEs, window functions, stored procedures, custom aggregations (PostgreSQL, SQL Server, MySQL, SQLite — extensible to any database with a Rust driver)
  • No ORM impedance mismatch — your data model is your data model, not a compromise
  • Debuggable queriesEXPLAIN ANALYZE on the exact SQL that serves your API

Every SQL view in FraiseQL follows the same pattern: an id column and a data JSONB column.

CREATE VIEW v_user AS
SELECT
u.id,
jsonb_build_object(
'id', u.id::text,
'name', u.name,
'email', u.email
) AS data
FROM tb_user u;

That’s it. The pattern is consistent across all databases, and you have a view that FraiseQL maps to a User GraphQL type.

To embed related data, reference another view’s .data column:

CREATE VIEW v_post AS
SELECT
p.id,
jsonb_build_object(
'id', p.id::text,
'title', p.title,
'content', p.content,
'author', vu.data
) AS data
FROM tb_post p
JOIN tb_user u ON u.pk_user = p.fk_user
JOIN v_user vu ON vu.id = u.id;

SELECT data FROM v_post returns the full nested response — author included. One query. No N+1.

CREATE VIEW v_user_with_posts AS
SELECT
u.id,
jsonb_build_object(
'id', u.id::text,
'name', u.name,
'posts', COALESCE(
jsonb_agg(vp.data ORDER BY vp.data->>'created_at' DESC)
FILTER (WHERE vp.id IS NOT NULL),
'[]'::jsonb
)
) AS data
FROM tb_user u
LEFT JOIN tb_post p ON p.fk_user = u.pk_user
LEFT JOIN v_post vp ON vp.id = p.id
GROUP BY u.id, u.name;

The pattern is consistent: JSON object building for single objects, array aggregation for lists.

The view pattern is simple and consistent enough that language models generate it accurately — including local models like Llama and Mistral running on your own hardware.

This is not accidental. SQL has fifty years of training data behind it. The FraiseQL naming convention (tb_*, v_*, pk_*, fk_*) removes ambiguity. The limited vocabulary (jsonb_build_object, jsonb_agg, COALESCE) gives models a small surface to get right.

  • Consistent structure — every view follows the same id + data pattern
  • Limited vocabularyjsonb_build_object, jsonb_agg, COALESCE, FILTER
  • Clear inputs — the table schema defines what goes into the view
  • No ambiguity — the naming convention (tb_*, v_*, pk_*, fk_*) removes guesswork

Given this table:

CREATE TABLE tb_order (
pk_order INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
fk_user INTEGER NOT NULL REFERENCES tb_user(pk_user),
total NUMERIC(10, 2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW()
);

Ask any LLM: “Write a FraiseQL view for this table that includes the user’s data from v_user.”

The output is predictable:

CREATE VIEW v_order AS
SELECT
o.id,
jsonb_build_object(
'id', o.id::text,
'total', o.total,
'status', o.status,
'created_at', o.created_at,
'customer', vu.data
) AS data
FROM tb_order o
JOIN tb_user u ON u.pk_user = o.fk_user
JOIN v_user vu ON vu.id = u.id;

You review it, adjust if needed, and commit. The SQL is yours.

The basic pattern covers most cases. But because you own the SQL, you can go further:

'order_total', (
SELECT SUM(oi.price * oi.quantity)
FROM tb_order_item oi
WHERE oi.fk_order = o.pk_order
)
'rank', ROW_NUMBER() OVER (PARTITION BY p.fk_user ORDER BY p.created_at DESC)
'badge', CASE
WHEN post_count >= 100 THEN 'power-user'
WHEN post_count >= 10 THEN 'active'
ELSE 'newcomer'
END
CREATE VIEW v_post_search AS
SELECT
p.id,
jsonb_build_object(
'id', p.id::text,
'title', p.title,
'snippet', ts_headline('english', p.content, query),
'rank', ts_rank(p.search_vector, query)
) AS data
FROM tb_post p, plainto_tsquery('english', $1) query
WHERE p.search_vector @@ query;

None of this requires FraiseQL to “support” it. It’s just SQL.

AspectORM/Query BuilderGraphQL Auto-GenerationFraiseQL
SQL visibilityHiddenHiddenWritten by you
Full SQL featuresSubsetSubsetEverything your database offers
Performance debuggingReverse-engineerReverse-engineerEXPLAIN ANALYZE directly
AI generationComplex (ORM DSL)N/ASimple (consistent pattern)
Learning curveORM-specific APIFramework-specific configSQL + one pattern