Coming up! Version 0.7-preview is out now! Check out the release notes.

On this page

SQL Endpoints

SQL endpoints turn a table, view, or stored procedure into a REST resource with OData querying, without you writing any SQL. Four backends are supported (SQL Server, PostgreSQL, MySQL, and SQLite), and Portway picks the correct driver automatically from the connection string in the environment's settings.json, so your endpoint configuration stays identical across providers.

TIP

Before exposing any table or view, it is worth double-checking the database permissions in play and the data those objects contain. Portway enforces column-level restrictions, but only for the columns you explicitly configure.

Info

Table-valued functions require SQL Server or PostgreSQL. Stored procedures are not available on SQLite. GET queries work across all four providers. See the SQL Providers reference for the full capability matrix.

Configuration

Create endpoints/SQL/{EndpointName}/entity.json:

json
{
  "DatabaseObjectName": "Products",
  "DatabaseSchema": "dbo",
  "PrimaryKey": "ProductID",
  "AllowedColumns": [
    "ProductID",
    "ProductName",
    "Category",
    "Price",
    "InStock"
  ],
  "AllowedMethods": ["GET", "POST", "PUT", "DELETE"],
  "AllowedEnvironments": ["dev", "test", "prod"]
}

Configuration properties

Every property this endpoint type accepts, with its type and default, is listed in Entity configuration.

Column aliases

Map internal column names to API-facing names using semicolon syntax in AllowedColumns:

json
{
  "DatabaseObjectName": "Items",
  "AllowedColumns": [
    "ItemCode;ProductNumber",
    "Description;ProductName",
    "Assortment;Category"
  ]
}

The API accepts and returns ProductNumber, ProductName, and Category. Portway maps them to the underlying column names before querying the database.

http
GET /api/prod/Items?$select=ProductNumber,ProductName&$filter=Category eq 'Electronics'

Querying with OData

All GET requests support OData query parameters:

Parameter Description Example
$select Return specific columns $select=ProductName,Price
$filter Filter rows $filter=Price gt 100
$orderby Sort results $orderby=ProductName desc
$top Limit row count $top=50
$skip Skip rows (for pagination) $skip=20
$count Add the total matching count as totalCount $count=true

Filter operators: eq, ne, gt, lt, ge, le, and, or, contains()

http
GET /api/prod/Products?$filter=Price gt 100 and InStock eq true&$orderby=Price desc&$top=25

Response format

json
{
  "success": true,
  "count": 25,
  "value": [
    { "ProductID": "abc123", "ProductName": "Gadget", "Price": 99.99 }
  ],
  "nextLink": "/api/prod/Products?$top=25&$skip=25"
}

Declare a to-one relationship to another SQL endpoint, and readers can pull the related row into the response with $expand:

json
{
  "DatabaseObjectName": "Items",
  "DatabaseObjectType": "Table",
  "AllowedColumns": ["ItemCode;ProductNumber", "Assortment;AssortmentID"],
  "Relationships": [
    { "Name": "Category", "Target": "Assortments", "LocalColumn": "Assortment", "TargetColumn": "AssortmentID" }
  ]
}
http
GET /api/prod/Products?$expand=Category

Portway joins the target and nests it under the navigation name, reusing the target's own column allowlist. It applies to Table and View endpoints and to-one navigations only; a table-valued function returns 400. The full contract and limits are in Expanding Related Data.

Write operations

POST: create a record

http
POST /api/prod/Products
Content-Type: application/json

{
  "ProductName": "New Gadget",
  "Category": "Electronics",
  "Price": 299.99,
  "InStock": true
}

PUT: update a record

Include the primary key in the request body:

http
PUT /api/prod/Products
Content-Type: application/json

{
  "ProductID": "abc123",
  "ProductName": "Updated Gadget",
  "Price": 249.99
}

DELETE: remove a record

http
DELETE /api/prod/Products?id=abc123

Stored procedures

For write operations that require business logic, validation, or audit logging, configure a stored procedure:

json
{
  "DatabaseObjectName": "ServiceRequests",
  "DatabaseSchema": "dbo",
  "Procedure": "dbo.sp_ManageServiceRequests",
  "AllowedMethods": ["GET", "POST", "PUT", "DELETE"],
  "AllowedColumns": ["RequestId", "CustomerCode", "Title", "Status"]
}

The procedure receives the HTTP method as @Method (INSERT, UPDATE, PATCH, DELETE). Requests that arrive as MERGE come through as PATCH (as an alias), no need to built seperate stored procedure branch for it:

sql
CREATE PROCEDURE [dbo].[sp_ManageServiceRequests]
    @Method      NVARCHAR(10),
    @id          UNIQUEIDENTIFIER = NULL,
    @CustomerCode NVARCHAR(20) = NULL,
    @Title       NVARCHAR(100) = NULL,
    @Status      NVARCHAR(20) = NULL,
    @UserName    NVARCHAR(50) = NULL
AS
BEGIN
    IF @Method = 'INSERT'
        -- insert logic
    ELSE IF @Method = 'UPDATE'
        -- update logic; use ISNULL(@Field, Field) to handle partial updates
    ELSE IF @Method = 'DELETE'
        -- delete logic
END

INFO

Stored procedures handle write operations only. GET requests use the standard OData query path against DatabaseObjectName directly.

Table write mode

When a stored procedure is more setup than the job needs, or the database cannot provide one at all (SQLite), an endpoint can opt into direct table writes:

json
{
  "DatabaseObjectName": "Bins",
  "WriteMode": "Table",
  "PrimaryKey": "Id",
  "AllowedMethods": ["GET", "POST", "PUT", "PATCH", "DELETE"],
  "AllowedColumns": ["Id", "Code", "Zone", "CapacityUnits"],
  "RequiredColumns": ["Code", "Zone"]
}

Portway then generates parameterized INSERT, UPDATE and DELETE statements through the same query compiler that powers OData reads. The mode is deliberately strict:

  • AllowedColumns and PrimaryKey are required; an endpoint missing either refuses all writes and logs a configuration error at startup.
  • Payload fields outside AllowedColumns reject the whole request rather than being dropped.
  • Updates and deletes only ever filter on the primary key, and a key that matches nothing returns 404.
  • WriteMode and Procedure are mutually exclusive; pick one strategy per endpoint.

Table mode works on every provider and is what enables full CRUD on SQLite. For production endpoints with business rules, validation chains or audit requirements, stored procedures remain the recommended path. A working example ships in the repository as WMS/Bins against the SQLite demo environment.

Table-valued functions

TVFs support parameterized queries, useful for reporting, generated datasets, or complex parameterized lookups that views cannot express.

json
{
  "DatabaseObjectName": "fn_GetDepartmentUsers",
  "DatabaseSchema": "dbo",
  "DatabaseObjectType": "TableValuedFunction",
  "FunctionParameters": [
    {
      "Name": "DepartmentId",
      "SqlType": "int",
      "Source": "Path",
      "Position": 1,
      "Required": false,
      "DefaultValue": "DEFAULT",
      "ValidationPattern": "^[0-9]+$"
    },
    {
      "Name": "UserCount",
      "SqlType": "int",
      "Source": "Query",
      "Required": false,
      "DefaultValue": "DEFAULT"
    }
  ],
  "AllowedColumns": [
    "user_id;UserId",
    "first_name;FirstName",
    "department_name;DepartmentName"
  ],
  "AllowedMethods": ["GET"]
}

Parameters can be sourced from Path, Query, or Header. Example calls:

http
GET /api/dev/Departments/5?UserCount=25
GET /api/dev/Departments?UserCount=50&$top=20&$orderby=FirstName

INFO

PrimaryKey is not applicable to TVF endpoints.

Column-level access control

Use AllowedColumns to exclude sensitive fields from API responses and requests. Any column not listed is invisible to callers, it is neither returned in GET results nor accepted in POST/PUT bodies.

json
{
  "DatabaseObjectName": "Customers",
  "AllowedColumns": [
    "CustomerID",
    "CompanyName",
    "ContactName"
  ]
}

Columns containing credentials, SSNs, financial data, or internal system fields should be excluded explicitly rather than relying on callers not to request them.

Troubleshooting

"Column not allowed": The column is not listed in AllowedColumns, or the name does not match exactly (case-sensitive).

"Method not allowed": Add the HTTP method to AllowedMethods, and ensure the stored procedure handles it if one is configured.

No results returned: Verify filter syntax, check that data exists in the target environment, and confirm database permissions for the connection string account.

Performance issues: Add indexes on columns used in $filter and $orderby. Use $top to limit result set size. Consider stored procedures for complex multi-table queries.

To increase log verbosity:

json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Debug"
    }
  }
}

Next steps

Last updated: 2026-09-07