Database Data APIs
Scenarios
This section describes how client-side applications connect to and use the database services provided by Huawei Cloud RDS Supabase.
- Traditional approach: The frontend does not connect directly to the database. Instead, you build your own backend services, write data access code for each table, and encapsulate it into APIs. The frontend accesses the database indirectly through these APIs. The more tables you have, the more repetitive work is required. Whenever a table changes, the corresponding APIs must also be updated, causing maintenance costs to accumulate as the business grows.
- RDS Supabase: Built on managed PostgreSQL, RDS Supabase automatically generates Data APIs based on table schemas. Each new table can be accessed through an API, although there may be a cache delay of a few seconds on the first access. APIs for subsequently added tables are generated as well. The frontend only needs the Supabase JavaScript SDK to access these APIs and perform create, read, update, and delete (CRUD) operations. Read operations can use the anon role, whereas write operations require a login state. This eliminates repetitive development at the source, so applications do not need to build their own backend APIs or write data access code for each table.
These APIs are generated by the PostgREST framework and can be accessed directly through the frontend SDK for read and write operations (subject to RLS policies and login state constraints). Row-level security (RLS) policies control access at the row level.
Related Concepts
Huawei Cloud RDS Supabase provides a managed PostgreSQL database and automatically generates Data APIs based on table schemas. Developers can perform CRUD operations without manually writing backend APIs. RLS also provides fine-grained data access control and isolation.
- PostgreSQL Database
Huawei Cloud RDS Supabase provides an independent, managed PostgreSQL database. It is a full PostgreSQL database, not a simplified version. You can use the full range of PostgreSQL capabilities without building or maintaining database servers yourself, including advanced features such as JSONB, full-text search, views, stored procedures, and triggers. You can also view and manage data through a graphical SQL interface.
When you create a Supabase application, the following two databases are automatically created on the managed PostgreSQL DB instance. For details, see Table 1.
- Automatically Generated Data APIs
After database tables are created, Supabase automatically generates corresponding HTTP endpoints for each table based on its schema. No backend code needs to be written manually.
These endpoints are generated by the PostgREST framework and support all CRUD operations. Creating a table provides a complete set of data access endpoints for that table. (There may be a cache delay of a few seconds the first time; to refresh the schema immediately, execute NOTIFY pgrst, 'reload schema'.) APIs for newly added tables are generated as well. Developers can focus on business logic without writing a data access object (DAO) layer or backend APIs, eliminating repetitive development from the source.
- Database Roles
Table 2 lists the database roles exposed by RDS Supabase for application-layer access. Applications and SDKs choose their access identities based on these roles.
Table 2 Role descriptions Role
Description
anon
Role for anonymous users; accessing using an API key, subject to RLS policies
authenticated
Role for authenticated users; accessing using a JWT, subject to RLS policies
service_role
Server-side role that bypasses RLS. Do not use it on the client side.
Among the three roles, anon and authenticated roles are intended for frontend use and are both subject to RLS policies. Their read and write permissions depend on the user's login state. The service_role role is intended for server-side use and bypasses RLS, and it must never be used on the client side.
- RLS Policies
RLS is a row-level access control mechanism built into PostgreSQL. When RLS is enabled on a table, all access requests are governed by policies. Policies define which rows a role can view or modify for each operation. Operations that do not match an applicable policy are denied by default. RDS Supabase uses RLS to isolate data between tenants and users in a shared database. Specifically, access through the anon and authenticated roles is subject to RLS policies, whereas the service_role role bypasses RLS. RLS policies must be used together with GRANT privileges.
- GRANT Privileges
GRANT is a PostgreSQL statement that assigns privileges to database roles. Table-level privileges determine whether a role can perform operations such as SELECT, INSERT, UPDATE, and DELETE on a table. These privileges provide table-level access control and are checked before RLS policies are applied: A role must first have the required privileges through GRANT, after which RLS determines which rows it can access. In RDS Supabase, the authenticated role has no privileges on newly created tables by default and must be explicitly granted privileges before use.
- Relationship Between RLS Policies and GRANT Privileges
RLS policies and GRANT privileges must be used together. Both are required for access.
Table 3 Comparison of GRANT and RLS mechanisms Mechanism
Layer
Function
GRANT
Table-level
It determines whether a role can perform SELECT, INSERT, UPDATE, and DELETE operations.
RLS policies
Row-level
After GRANT privileges are granted, RLS determines which rows can be viewed or modified.
Execution flow: Request → GRANT check → RLS policy check → Return results
- GRANT without RLS policies: When RLS is enabled, access to rows is denied by default. Queries return empty results.
- RLS policies without GRANT privileges: The role lacks table-level privileges, so requests are rejected.
Table Creation and Permissions Configuration
The following example uses a product pricing scenario. The products table stores product information, and the product_pricings table stores pricing tiers and prices for each product. The two tables are linked by product_id. When a product is deleted, its associated pricing records are also deleted. The example defines access boundaries based on business roles: Visitors can browse data, authenticated operations staff can manage it, and backend services can perform administrative operations. Based on these requirements, the following steps provide a complete configuration of tables, RLS policies, and GRANT privileges.
The following steps provide the complete SQL statements for table creation and permissions configuration. You can execute the statements individually or all at once in the SQL Editor of the Supabase Dashboard.
- Create data tables.
Define the products table and the product_pricings table, linking them through a product_id foreign key and cascading the deletion of pricing records when a product is deleted.
-- products: Product table CREATE TABLE products ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, category TEXT, description TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -- product_pricings: Product pricing table CREATE TABLE product_pricings ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE, tier TEXT NOT NULL, price NUMERIC(10,2) NOT NULL, unit TEXT DEFAULT 'CNY/month', effective_from DATE DEFAULT current_date, created_at TIMESTAMPTZ DEFAULT now() );
Field descriptions:
- id: Primary key with a value generated automatically by the database. GENERATED BY DEFAULT allows explicit ID values to be specified in scenarios such as upsert. The default in the official template is GENERATED ALWAYS, which is stricter because it prohibits manual insertion of id.
- product_id: A foreign key referencing products(id). ON DELETE CASCADE means that deleting a product also deletes its associated pricing records.
- price: NUMERIC(10,2) allows up to 10 digits in total, including 2 decimal places, leaving up to 8 digits before the decimal point.
- updated_at: The default value is set only when a row is inserted. To update this field automatically when a row changes, you must configure a trigger separately.
- Enable RLS.
Once RLS is enabled, access to each table is subject to RLS policies. Operations are denied by default if no applicable policy is configured.
ALTER TABLE products ENABLE ROW LEVEL SECURITY; ALTER TABLE product_pricings ENABLE ROW LEVEL SECURITY;
Enabling RLS on the product and pricing tables is like locking both tables. From this point on, which products and prices each role can view or modify is determined by the RLS policies configured in the next step. Product and pricing data are no longer openly accessible to all users, and access is controlled row by row according to the user's role.
- Configure RLS policies.
Configure policies by role and operation. The anon role (anonymous users) is granted read access only, whereas the authenticated role (authenticated users) is granted permission to perform CRUD operations.
-- anon (anonymous users): read-only CREATE POLICY "anon_select_products" ON public.products FOR SELECT TO anon USING (true); CREATE POLICY "anon_select_product_pricings" ON public.product_pricings FOR SELECT TO anon USING (true); -- authenticated (authenticated users): CRUD CREATE POLICY "authenticated_select_products" ON public.products FOR SELECT TO authenticated USING (true); CREATE POLICY "authenticated_select_product_pricings" ON public.product_pricings FOR SELECT TO authenticated USING (true); CREATE POLICY "authenticated_insert_products" ON public.products FOR INSERT TO authenticated WITH CHECK (true); CREATE POLICY "authenticated_insert_product_pricings" ON public.product_pricings FOR INSERT TO authenticated WITH CHECK (true); CREATE POLICY "authenticated_update_products" ON public.products FOR UPDATE TO authenticated USING (true) WITH CHECK (true); CREATE POLICY "authenticated_update_product_pricings" ON public.product_pricings FOR UPDATE TO authenticated USING (true) WITH CHECK (true); CREATE POLICY "authenticated_delete_products" ON public.products FOR DELETE TO authenticated USING (true); CREATE POLICY "authenticated_delete_product_pricings" ON public.product_pricings FOR DELETE TO authenticated USING (true);
These policies define the operations permitted for each role on the product and pricing tables. All policies allow access to every row, so the restrictions apply to the types of operations rather than to specific rows.
- Visitors (anon) can only view products and prices.
- Authenticated users (authenticated) can create, update, and delete products and pricing records.
- service_role is not included in these policies because it bypasses RLS. For details, see 4.
- Grant table-level privileges (GRANT).
GRANT determines whether a role can perform a particular table-level operation, whereas RLS determines which rows the role can access. Both are required for access.
-- anon: Read-only; grant SELECT only GRANT SELECT ON TABLE public.products TO anon; GRANT SELECT ON TABLE public.product_pricings TO anon; -- authenticated: CRUD; grant all four operation privileges GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.products TO authenticated; GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.product_pricings TO authenticated; -- service_role: Bypasses RLS; grant all privileges GRANT ALL ON ALL TABLES IN SCHEMA public TO service_role;
The permissions for each role on the product and pricing tables are summarized below.
Table 4 Example permission design Role
products (Product Table)
product_pricings (Product Pricing Table)
anon (visitor)
Read-only
Read-only
authenticated (logged-in user)
CRUD
CRUD
service_role (server-side)
All (bypassing RLS)
All (bypassing RLS)
From a business perspective, the product and pricing tables contain catalog data displayed to users. Visitors (anon) can only browse products and prices, preventing unauthorized changes to the catalog. Authenticated operations staff (authenticated) can publish or unpublish products and adjust prices to support routine operations.
- Refresh the schema cache.
Execute the following statement to immediately refresh the PostgREST schema cache, allowing newly created tables to be accessed instantly by the Data API without waiting for the periodic automatic refresh.
NOTIFY pgrst, 'reload schema';
SDK Operations
The application frontend uses the Supabase JavaScript SDK to access the backend Data API. The following examples assume that the SDK has been initialized (see Preparations) and that the client variable is supabase. The client is initialized with a Publishable Key (formerly called the Anon Key), and requests are read-only by default.
The Data API determines which database role to use based on the key used for the request and the user's login state. A client-side Publishable Key (formerly called the Anon Key) maps to the read-only anon role when the user is not logged in and to the authenticated role (supporting CRUD operations, subject to RLS policies) after the user logs in. A server-side Secret Key (formerly called the Service Role Key) maps to the service_role, which bypasses RLS. When the SDK is initialized with a Publishable Key, requests from unauthenticated users are initiated as the anon role.
Publishable Keys are safe to use in frontend code, with RLS controlling which rows can be accessed. Secret Keys bypass RLS and must never be included in frontend code. Queries can be performed as the anon role. Write operations, including inserts, updates, upserts, and deletes, must be performed as the authenticated role. The sign-in credentials are issued through the user authentication flow, which is not covered in this guide.
- Retrieve data. (SELECT)
Scenario: Users browse the product list on a product page or filter products by category, such as viewing only products in the compute category. These are read operations and can be performed without logging in.
// Standard practice: Deconstruct data and error, check for errors first, and then use data. const { data, error } = await supabase.from('products').select() if (error) { console.error(error); return } // data is an array of query results, such as [{ id: 1, name: 'Elastic Cloud Server (ECS)', category: 'Compute', ... }] // Subsequent examples omit error handling for brevity. Always include error handling in production. // Conditional query + specified fields const { data } = await supabase .from('products') .select('id, name, category') .eq('category', 'Compute') - Insert data. (INSERT)
Scenario: Operations staff add new products, such as Elastic Cloud Server (ECS) or Object Storage Service (OBS). To add a pricing tier, such as pay-per-use or monthly, insert a row into product_pricings in the same way.
The following examples assume that the user is logged in and the client makes requests as the authenticated role.
// Insert a single row. await supabase.from('products').insert({ name: 'Elastic Cloud Server (ECS)', category: 'Compute', description: 'Elastic Cloud Server' }) // Insert multiple rows. await supabase.from('products').insert([ { name: 'Elastic Cloud Server (ECS)', category: 'Compute' }, { name: 'Object Storage Service (OBS)', category: 'Storage' } ])A client initialized with only a Publishable Key and used without logging in operates as a read-only role in this setup. Write operations initiated directly will be rejected. To perform them successfully, requests must be made as the authenticated role.
- Update data. (UPDATE)
Scenario: Operations staff modify product information, such as updating the description of the product with ID 1. To change a price, update the corresponding pricing tier in the product_pricings table.
await supabase.from('products').update({ description: 'Updated description' }).eq('id', 1)update() must be used with a filter. Otherwise, it updates every row in the table.
- Upsert data. (UPSERT)
Scenario: Products are synchronized or imported in batches. If a product already exists (matched by the primary key or a specified column), it is updated. If the product does not exist, a new row is inserted. This is commonly used to synchronize product catalogs from external systems.
If a conflict occurs, the existing row is updated. If there is no conflict, a new row is inserted. onConflict specifies the column used to detect conflicts and defaults to the primary key. To detect conflicts using a non-primary-key column, you must first define a unique constraint on that column.
// Conflict based on primary key: Update the row if id=1 exists. Insert a row if not. const { data } = await supabase .from('products') .upsert({ id: 1, name: 'Elastic Cloud Server (ECS)', category: 'Compute' }) .select() // Conflict based on a non-primary-key column: A unique constraint must be created for the column first. // ALTER TABLE products ADD CONSTRAINT products_name_key UNIQUE (name); const { data } = await supabase .from('products') .upsert({ name: 'Elastic Cloud Server (ECS)', category: 'Compute' }, { onConflict: 'name' }) .select() - Delete data. (DELETE) Scenario: Operations staff remove a product from the catalog. Due to the foreign key's ON DELETE CASCADE option, the product's pricing tiers are deleted as well.
await supabase.from('products').delete().eq('id', 1)delete() must be used with a filter. Otherwise, it deletes every row in the table.
The following examples use the products and product_pricings tables created in Table Creation and Permissions Configuration to demonstrate join queries.
PostgREST automatically identifies relationships between tables through foreign keys. You can nest related tables in select() using the related_table(columns) syntax to retrieve related data in a single query without writing a JOIN manually.
- Query pricing for products (one-to-many): Retrieve products and all their pricing tiers. Child rows are returned as an array.
// Query products and all pricing tiers for each product. const { data } = await supabase .from('products') .select(` id, name, category, product_pricings(id, tier, price, unit) `) // Example data: [{ id: 1, name: 'Elastic Cloud Server (ECS)', category: 'Compute', product_pricings: [{ id: 1, tier: 'Monthly', price: 99.00, unit: 'CNY/month' }] }]
- Query products for pricing (many-to-one): Query the pricing table and retrieve the associated product for each pricing record.
// Query a pricing record and retrieve its associated product. const { data } = await supabase .from('product_pricings') .select('id, tier, price, products(id, name, category)')
- Join queries with filtering and sorting: Retrieve Compute products and their pricing tiers, sorted by product name.
const { data } = await supabase .from('products') .select('id, name, product_pricings(tier, price)') .eq('category', 'Compute') .order('name')
- Query the parent table based on child-table conditions (inner join): Return only products with at least one pricing record whose price is below 100. Use !inner to enforce an inner join with the filter applied to the embedded column.
// Inner join: Return only products with pricing tiers priced below 100. const { data } = await supabase .from('products') .select('id, name, product_pricings!inner(tier, price)') .lt('product_pricings.price', 100)Removing !inner changes the join to a left join: All products are returned, but only pricing tiers priced below 100 are included for each product.
Reference
The following open-source Supabase documents are provided for reference only.
What is your overall rating for this page?
Thank you very much for your feedback. We will continue working to improve the documentation.See the reply and handling status in My Cloud VOC.
For any further questions, feel free to contact us through the chatbot.
Chatbot