Prisma Migration
Migrating from Prisma ORM.
This guide covers migrating from Hasura (auto-generated GraphQL from database introspection) to FraiseQL (code-first GraphQL backed by hand-written SQL views and PostgreSQL functions).
| Feature | Hasura | FraiseQL |
|---|---|---|
| Approach | Database-first (auto-schema from DB) | Code-first (schema from Python/TS/Go decorators) |
| Configuration | Web UI + YAML | SDK decorators + TOML |
| Type Safety | GraphQL generated | Native SDK types (Python, TypeScript, Go) |
| Hosting | Cloud or self-hosted | Any server — Docker, K8s, bare metal |
| Custom Logic | Actions + Webhooks | PostgreSQL functions (fn_*) |
| Federation | No | Yes (multi-database) |
| Performance | Good | Excellent (pre-compiled SQL views) |
| Runtime | Node.js / Haskell | Rust binary |
| Learning Curve | Low (UI-driven) | Medium (code-driven) |
Use this table as a translation guide as you work through the migration:
| Hasura | FraiseQL Equivalent |
|---|---|
| Track table | Create SQL view (v_*) |
| Permissions (YAML) | PostgreSQL Row-Level Security via SQL view WHERE clause |
| Event triggers | Observers (TOML [observers] section) |
| Actions | SQL functions (fn_*) + mutations |
| Remote schemas | Federation (multi-database in one binary) |
| Computed fields | SQL view expressions |
| Relationships (YAML) | Typed fields on @fraiseql.type backed by SQL JOIN |
| Hasura Console | fraiseql.toml + SDK decorators |
Unlike Hasura, FraiseQL also serves REST and gRPC alongside GraphQL. After migration, your API is accessible to non-GraphQL clients without a separate gateway.
FraiseQL asks you to write views. In exchange:
_aggregate, _by_pk, _stream variants you didn’t ask for.WHERE clauses instead of Hasura’s permission rule engine.The migration involves creating v_* views that match your existing data model. For a simple case this is 30–60 minutes of SQL. For a complex case with permissions it replaces Hasura’s YAML rules with SQL WHERE conditions on the view.
Hasura auto-generates a GraphQL schema by introspecting your database tables. Every column and relationship becomes a queryable field automatically.
FraiseQL uses hand-written SQL views (v_*) for reads and PostgreSQL functions (fn_*) for writes. The Rust binary serves a GraphQL API derived from your decorator-annotated schema types — giving you explicit control over exactly what data is exposed and how it is shaped.
Export Hasura Configuration
# Get current Hasura metadatahasura metadata export
# Review generated metadatacat metadata/databases/default/tables/Map Hasura Tables to FraiseQL Types
Each Hasura table becomes a FraiseQL @fraiseql.type. Relationships defined in Hasura YAML become typed fields on the class.
table: name: user schema: publicselect_permissions: - role: user permission: columns: [id, email, name] filter: id: _eq: X-Hasura-User-Idrelationships: - name: posts type: array using: foreign_key_constraint_on: column: user_id table: name: post schema: public@fraiseql.typeclass User: id: ID email: str name: str posts: list['Post'] # Relationship via SQL view JOIN
@fraiseql.query(requires_scope="read:User")def user(id: ID) -> User: """Get user (enforces RLS filter via SQL view).""" return fraiseql.config(sql_source="v_user")import { Type, Query, ID } from 'fraiseql';
@Type()class User { id!: ID; email!: string; name!: string; posts!: Post[];}
@Query({ sqlSource: 'v_user', requiresScope: 'read:User' })function user(id: ID): Promise<User> { return Promise.resolve({} as User);}import "github.com/fraiseql/fraiseql-go"
type User struct { ID fraiseql.ID `fraiseql:"id"` Email string `fraiseql:"email"` Name string `fraiseql:"name"` Posts []Post `fraiseql:"posts"`}
func init() { fraiseql.Query("user", fraiseql.QueryConfig{ SQLSource: "v_user", RequiresScope: "read:User", }, func(args fraiseql.Args) (User, error) { return User{}, nil })}Replicate Permissions via SQL Views
Hasura row-level permissions become WHERE clauses in your PostgreSQL views. The FraiseQL Rust binary passes the authenticated user’s context as a PostgreSQL session variable.
select_permissions: - role: user permission: columns: [id, name, email] filter: id: _eq: X-Hasura-User-Id@fraiseql.query(requires_scope="read:User")def my_profile() -> User: """User can only see their own profile.""" return fraiseql.config(sql_source="v_user_filtered")-- v_user_filtered enforces row-level access in SQLCREATE VIEW v_user_filtered ASSELECT u.id, jsonb_build_object( 'id', u.id::text, 'identifier', u.identifier, 'email', u.email, 'name', u.name ) AS dataFROM tb_user uWHERE u.id = current_setting('app.current_user_id')::uuid;import { Query, RequiresScope, ID } from 'fraiseql';
@Query({ sqlSource: 'v_user_filtered' })@RequiresScope('read:User')function myProfile(): Promise<User> { return Promise.resolve({} as User);}-- v_user_filtered enforces row-level access in SQLCREATE VIEW v_user_filtered ASSELECT u.id, jsonb_build_object( 'id', u.id::text, 'identifier', u.identifier, 'email', u.email, 'name', u.name ) AS dataFROM tb_user uWHERE u.id = current_setting('app.current_user_id')::uuid;fraiseql.Query("myProfile", fraiseql.QueryConfig{ SQLSource: "v_user_filtered", RequiresScope: "read:User",}, func(args fraiseql.Args) (User, error) { return User{}, nil })-- v_user_filtered enforces row-level access in SQLCREATE VIEW v_user_filtered ASSELECT u.id, jsonb_build_object( 'id', u.id::text, 'identifier', u.identifier, 'email', u.email, 'name', u.name ) AS dataFROM tb_user uWHERE u.id = current_setting('app.current_user_id')::uuid;The FraiseQL Rust binary sets app.current_user_id (and other claims) as PostgreSQL session variables from the JWT at request time. See Authentication for full JWT and session variable configuration.
Verify your permission migration works correctly by testing with both an admin token and a viewer token:
# Admin role: full access — should return all userscurl -X POST http://localhost:8080/graphql \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "{ users { id name email } }"}' | jq '.data.users | length'# Expect: total user count (e.g. 42)
# Viewer role: filtered access — should return only the viewer's own profilecurl -X POST http://localhost:8080/graphql \ -H "Authorization: Bearer $VIEWER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "{ myProfile { id name email } }"}' | jq '.data.myProfile'# Expect: single user object matching the viewer's own account
# Viewer role: cross-user access — should be filtered out by the SQL viewcurl -X POST http://localhost:8080/graphql \ -H "Authorization: Bearer $VIEWER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "{ user(id: \"other-user-id\") { id name } }"}' | jq '.data.user'# Expect: null (filtered by SQL view WHERE clause — not an authorization error)Migrate Custom Actions to PostgreSQL Functions
Hasura Actions are webhook-based — they call an external HTTP endpoint. FraiseQL replaces these with PostgreSQL functions (fn_*) or observer hooks that trigger side effects.
actions: - name: sendEmail definition: kind: action arguments: - name: email type: String! - name: message type: String! output_type: ActionOutput webhook_url: https://myapp.com/send-email timeout: 30@fraiseql.inputclass SendEmailInput: email: str message: str
@fraiseql.mutation(sql_source="fn_send_email", operation="CUSTOM")def send_email(input: SendEmailInput) -> User: """Queue email via PostgreSQL function.""" pass-- Business logic lives in the databaseCREATE FUNCTION fn_send_email(p_email TEXT, p_message TEXT)RETURNS mutation_response AS $$DECLARE v_result mutation_response;BEGIN -- Insert into outbox for async processing INSERT INTO tb_email_outbox (recipient, body) VALUES (p_email, p_message);
v_result.status := 'success'; v_result.message := 'Email queued'; RETURN v_result;EXCEPTION WHEN OTHERS THEN v_result.status := 'error'; v_result.message := 'Failed to queue email.'; RETURN v_result;END;$$ LANGUAGE plpgsql;import { Input, Mutation } from 'fraiseql';
@Input()class SendEmailInput { email!: string; message!: string;}
@Mutation({ sqlSource: 'fn_send_email', operation: 'CUSTOM' })function sendEmail(input: SendEmailInput): Promise<User> { return Promise.resolve({} as User);}-- Business logic lives in the databaseCREATE FUNCTION fn_send_email(p_email TEXT, p_message TEXT)RETURNS mutation_response AS $$DECLARE v_result mutation_response;BEGIN -- Insert into outbox for async processing INSERT INTO tb_email_outbox (recipient, body) VALUES (p_email, p_message);
v_result.status := 'success'; v_result.message := 'Email queued'; RETURN v_result;EXCEPTION WHEN OTHERS THEN v_result.status := 'error'; v_result.message := 'Failed to queue email.'; RETURN v_result;END;$$ LANGUAGE plpgsql;import "github.com/fraiseql/fraiseql-go"
type SendEmailInput struct { Email string `fraiseql:"email"` Message string `fraiseql:"message"`}
func init() { fraiseql.Mutation("sendEmail", fraiseql.MutationConfig{ SQLSource: "fn_send_email", Operation: "CUSTOM", }, func(input SendEmailInput) (interface{}, error) { return nil, nil })}-- Business logic lives in the databaseCREATE FUNCTION fn_send_email(p_email TEXT, p_message TEXT)RETURNS mutation_response AS $$DECLARE v_result mutation_response;BEGIN -- Insert into outbox for async processing INSERT INTO tb_email_outbox (recipient, body) VALUES (p_email, p_message);
v_result.status := 'success'; v_result.message := 'Email queued'; RETURN v_result;EXCEPTION WHEN OTHERS THEN v_result.status := 'error'; v_result.message := 'Failed to queue email.'; RETURN v_result;END;$$ LANGUAGE plpgsql;Migrate Subscriptions to NATS
subscription OnUserCreated { user(event: insert) { id name email }}@fraiseql.subscription(entity_type="User", topic="created")def user_created() -> User: """Subscribe to new users.""" passUpdate Client Queries
FraiseQL uses a slightly different filter syntax from Hasura. Update your client queries accordingly.
query { user(where: { email: { _eq: "alice@example.com" } posts: { title: { _ilike: "hello%" } } }) { id email }}query { user( email_eq: "alice@example.com" posts_title_contains: "hello" ) { id email }}Ordering and pagination syntax is compatible:
query { users(order_by: { created_at: desc }) { id name }}query { users(orderBy: "created_at DESC") { id name }}Pagination is identical in both:
query { users(limit: 10, offset: 20) { id name }}Performance Test and Decommission
Once all client queries are migrated and validated, decommission Hasura. FraiseQL’s pre-compiled SQL views typically deliver 3-5x better performance than Hasura’s auto-generated queries. See Performance Benchmarks for methodology.
Hasura auto-generates SQL from the GraphQL query at runtime. This is flexible but can produce suboptimal queries, especially for nested relationships.
FraiseQL uses hand-written SQL views that you optimize yourself. The Rust binary executes pre-compiled queries — no runtime SQL generation.
Hasura query (10 users with posts): users: 1 query user[0].posts: 1 query (N+1!) user[1].posts: 1 query ... (8 more) Total: 11 queries
FraiseQL query (10 users with posts): users: 1 query (v_user) posts (batched): 1 query (v_post WHERE user_id IN (...)) Total: 2 queries| Hasura Cloud | FraiseQL (Self-hosted) | |
|---|---|---|
| GraphQL engine | Paid tier required for production features | Open source (Apache 2.0) |
| Server hosting | Included in cloud plan | Your own server or cloud VM |
| Database | Extra (managed database add-on) | Existing or separate managed DB |
FraiseQL is self-hosted, so your total cost depends on your infrastructure. For many teams, running a single Rust binary alongside an existing PostgreSQL instance results in significantly lower costs than a managed GraphQL cloud service, but your mileage will vary.
v_*) replicating Hasura’s select permissionsfn_*) replacing Hasura actionsPrisma Migration
Migrating from Prisma ORM.
Apollo Migration
Migrating from Apollo Server.
REST Migration
Migrating from REST APIs.
Getting Started
Learn FraiseQL fundamentals from scratch.
Export and compare schemas:
# Export Hasura metadatahasura metadata export
# Compile FraiseQL schemafraiseql compile --output schema.fraiseql.json
# Compare type countscat metadata/databases/default/tables/*.yaml | grep "^table:" | wc -lcat schema.fraiseql.json | jq '.types | length'Test query parity:
# Hasura querycurl -X POST http://localhost:8080/v1/graphql \ -H "Content-Type: application/json" \ -H "X-Hasura-Admin-Secret: secret" \ -d '{"query": "{ users { id name } }"}' \ > hasura_result.json
# FraiseQL querycurl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ users { id name } }"}' \ > fraiseql_result.json
# Compare field structurediff <(jq '.data.users[0] | keys' hasura_result.json) \ <(jq '.data.users[0] | keys' fraiseql_result.json)Verify RLS equivalence:
# Test with user tokencurl -X POST http://localhost:8080/graphql \ -H "Authorization: Bearer $USER_TOKEN" \ -d '{"query": "{ users { id email } }"}'
# Verify user only sees their own data# (same behavior as Hasura with permissions)Test event triggers → observers:
# Create a recordcurl -X POST http://localhost:8080/graphql \ -d '{"query": "mutation { createUser(...) { id } }"}'
# Check webhook was called (in FraiseQL observer)tail -f /var/log/fraiseql/observers.logPerformance comparison:
# Benchmark Hasuraab -n 1000 -c 10 -p query.json \ -H "X-Hasura-Admin-Secret: secret" \ http://localhost:8080/v1/graphql
# Benchmark FraiseQLab -n 1000 -c 10 -p query.json \ http://localhost:8080/graphqlSymptoms: Users can see all data instead of just their own.
Solution:
Verify the SQL view applies the correct WHERE filter. In FraiseQL, row-level access is enforced in the view itself — there is no Python context object at runtime. The Rust binary sets a PostgreSQL session variable before executing the query:
-- Correct: row-level filter is in the SQL viewCREATE VIEW v_user_filtered ASSELECT u.id, jsonb_build_object('id', u.id::text, 'email', u.email, 'name', u.name) AS dataFROM tb_user uWHERE u.id = current_setting('app.current_user_id')::uuid;Check that the JWT claim is being injected correctly via fraiseql.toml:
[security.pkce] issuer_url = “https://auth.example.com” client_id = “my-app” redirect_uri = “https://app.example.com/callback”
3. Verify the session variable is set before queries run by checking the FraiseQL server logs at debug level:```bashRUST_LOG=debug fraiseql runSymptoms: Observers don’t execute when data changes.
Solution:
Observers in FraiseQL are configured in fraiseql.toml, not via Python decorators. The fraiseql Python package has no observer export — observers are a TOML-level and runtime concern.
Check that the [observers] section is present in fraiseql.toml:
[observers]backend = "nats"nats_url = "nats://localhost:4222"Verify the observer logs at startup:
fraiseql run 2>&1 | grep "observer"Test NATS connectivity:
nats pub test "hello"Symptoms: Hasura had field-level permissions, FraiseQL exposes everything.
Solution:
To hide sensitive fields, simply omit them from the @fraiseql.type class and from the SQL view’s jsonb_build_object. fraiseql.field() accepts requires_scope, on_deny, deprecated, and description — there is no exclude parameter.
To restrict access to a field to authorized users only, use requires_scope:
from typing import Annotatedimport fraiseql
@fraiseql.typeclass User: id: ID name: str # Requires a specific scope — rejects unauthorized requests by default email: Annotated[str, fraiseql.field(requires_scope="read:User.email")]To create separate views for different roles, each view must still return (id, data):
-- Public view: omits email from the JSONB dataCREATE VIEW v_user_public ASSELECT u.id, jsonb_build_object('id', u.id::text, 'name', u.name) AS dataFROM tb_user u;
-- Admin view: includes all fieldsCREATE VIEW v_user_admin ASSELECT u.id, jsonb_build_object( 'id', u.id::text, 'identifier', u.identifier, 'name', u.name, 'email', u.email, 'created_at', u.created_at ) AS dataFROM tb_user u;Symptoms: Nested queries return null or empty arrays.
Solution:
Check SQL view has JOIN using integer FKs (not UUID):
-- View should JOIN via the pk_/fk_ integer pairSELECT ... FROM tb_user uLEFT JOIN tb_post p ON p.fk_user = u.pk_userGROUP BY u.pk_user, u.idVerify foreign keys exist:
SELECT conname, conrelid::regclass, confrelid::regclassFROM pg_constraint WHERE contype = 'f';Test the view directly:
SELECT data FROM v_user WHERE id = '123';-- Should include nested postsSymptoms: FraiseQL is slower than Hasura.
Solution:
Check for missing indexes (Hasura auto-creates, FraiseQL doesn’t):
-- List all indexesSELECT indexname, tablename FROM pg_indexesWHERE tablename LIKE 'tb_%' ORDER BY tablename;Create necessary indexes:
CREATE INDEX idx_tb_user_email ON tb_user(email);CREATE INDEX idx_tb_post_fk_user ON tb_post(fk_user);Analyze query plans:
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)SELECT data FROM v_user WHERE email = 'test@test.com';