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 (@observer decorator) |
| 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 |
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(sql_source="v_user", requires_scope="read:User")def user(id: ID) -> User: """Get user (enforces RLS filter via SQL view).""" passimport { 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(sql_source="v_user_filtered", requires_scope="read:User")def my_profile() -> User: """User can only see their own profile.""" pass-- v_user_filtered enforces row-level access in SQLCREATE VIEW v_user_filtered ASSELECT * FROM tb_userWHERE 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 * FROM tb_userWHERE 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 * FROM tb_userWHERE id = current_setting('app.current_user_id')::uuid;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")def send_email(input: SendEmailInput) -> ActionOutput: """Send email via PostgreSQL function.""" pass-- Business logic lives in the databaseCREATE FUNCTION fn_send_email(p_email TEXT, p_message TEXT)RETURNS TABLE(success BOOL, message TEXT) AS $$BEGIN -- Insert into outbox for async processing INSERT INTO tb_email_outbox (recipient, body) VALUES (p_email, p_message); RETURN QUERY SELECT true, 'Email queued'::TEXT;END;$$ LANGUAGE plpgsql;import { input, mutation } from 'fraiseql';
@input()class SendEmailInput { email!: string; message!: string;}
@mutation({ sqlSource: 'fn_send_email' })function sendEmail(input: SendEmailInput): Promise<ActionOutput> { return Promise.resolve({} as ActionOutput);}-- Business logic lives in the databaseCREATE FUNCTION fn_send_email(p_email TEXT, p_message TEXT)RETURNS TABLE(success BOOL, message TEXT) AS $$BEGIN INSERT INTO tb_email_outbox (recipient, body) VALUES (p_email, p_message); RETURN QUERY SELECT true, 'Email queued'::TEXT;END;$$ LANGUAGE plpgsql;import "github.com/fraiseql/fraiseql-go"
type SendEmailInput struct { Email string `fraiseql:"email"` Message string `fraiseql:"message"`}
type ActionOutput struct { Success bool `fraiseql:"success"` Message string `fraiseql:"message"`}
func init() { fraiseql.Mutation("sendEmail", fraiseql.MutationConfig{ SQLSource: "fn_send_email", }, func(input SendEmailInput) (ActionOutput, error) { return ActionOutput{}, nil })}-- Business logic lives in the databaseCREATE FUNCTION fn_send_email(p_email TEXT, p_message TEXT)RETURNS TABLE(success BOOL, message TEXT) AS $$BEGIN INSERT INTO tb_email_outbox (recipient, body) VALUES (p_email, p_message); RETURN QUERY SELECT true, 'Email queued'::TEXT;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.
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 RLS is enabled on tables:
SELECT relname, relrowsecurity FROM pg_class WHERE relname LIKE 'tb_%';Check policy exists:
SELECT * FROM pg_policies WHERE tablename = 'tb_user';Ensure tenant_id is being set:
# Check context in resolverprint(ctx.tenant_id) # Should not be NoneSymptoms: Observers don’t execute when data changes.
Solution:
Verify observer is registered:
# Check logs during startupfraiseql run 2>&1 | grep "observer"Check observer syntax:
@fraiseql.observer( entity="User", # Must match table name event="INSERT")Test NATS connection:
docker-compose exec nats nats server infoSymptoms: Hasura had field-level permissions, FraiseQL exposes everything.
Solution:
Use field-level decorators:
@fraiseql.typeclass User: id: str email: str # Hide sensitive fields password_hash: Annotated[str, fraiseql.field(exclude=True)]Or create separate views for different roles:
CREATE VIEW v_user_public ASSELECT id, name FROM tb_user;
CREATE VIEW v_user_admin ASSELECT id, name, email, created_at FROM tb_user;Symptoms: Nested queries return null or empty arrays.
Solution:
Check SQL view has JOIN:
-- View should JOIN related tablesSELECT ... FROM tb_user uLEFT JOIN tb_post p ON p.user_id = 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_user_email ON tb_user(email);CREATE INDEX idx_post_user_id ON tb_post(user_id);Analyze query plans:
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)SELECT data FROM v_user WHERE email = 'test@test.com';