Designing the Interface
Based on the SQRL script, DataSQRL generates the interface for the compiled data pipeline. DataSQRL supports the following interfaces:
- Data Product (Data Lake Views and Database Views)
- GraphQL (Mutations, Queries, and Subscriptions)
- MCP (Tooling and Resources)
- REST (GET and POST)
For data products, DataSQRL generates view definitions as deployment assets in build/deploy/plan which can be queried directly.
The last three are APIs that can be invoked programmatically. Every compiled API model uses the GraphQL execution model.
The protocols compiler configuration controls which public API protocols the server exposes.
Data Productsโ
For data products, each visible table defined in the SQRL script is exposed as a view or physical table depending on the pipeline optimization. The mapping between visible tables in the SQRL script and exposed tables in the interface is 1-to-1.
We recommend generating unique table names for the physical tables by configuring a table-name suffix in the connector configuration, e.g. by configuring the table-name for postgres or the catalog-table for iceberg to ${sqrl:table-name}_MY_SUFFIX . This separates views from physical tables to provide modularity and support updates without impacting downstream consumers.
APIsโ
When a server engine is configured, the tables, relationships, and functions defined in a SQRL script map to API endpoints exposed by the server. DataSQRL builds an object-relationship model from the tables and relationships between them. Tables are mapped to objects with each scalar column as a field. Fields that are nested rows are mapped to child objects with field name as the parent-to-child relationship. Relationships defined between tables are mapped to relationships between the corresponding objects.
GraphQLโ
DataSQRL uses the GraphQL data model as the base model for all API access to data because there is a natural 1-to-1 mapping between the object-relationship model of a SQRL project and GraphQL schema: Each object maps to a type or input and each relationship maps to a relationship field on the respective types.
Model-to-Schema Mappingโ
Specifically, tables and functions are exposed as query endpoints of the same name and argument signature (i.e. the argument names and types match).
Tables/functions defined with the SUBSCRIBE keyword are exposed as subscriptions.
Internal table sources are exposed as mutations with the input type identical to the columns in the table excluding computed columns.
In addition, the result type of the endpoint matches the schema of the table or function. That means, each field of the result type matches a column or relationship on the table/function by name and the field type is compatible. The field type is compatible with the column/relationship type iff:
- For scalar or collection types there is a native mapping from one type system to the other
- For structured types (i.e. nested or relationship), the mapping applies recursively.
Base Tablesโ
To avoid generating multiple redundant result types in the API interface, the compiler infers the base table for each defined table and function.
The base table for a defined table or function is the right-most table in the relational tree of the SELECT query from the definition body if and only if that table type is equal to the defined table type. If no such table exists, the base table is the table itself.
The result type for a table or function is the result type generated for that table's base table.
Hidden columns, i.e. columns where the name starts with an underscore _, are not included in the generated result type.
Schema Generationโ
If no GraphQL schema is configured, the compiler infers one from the SQRL script and writes it to build/inferred_schema.graphqls.
To provide and customize a schema for the default v1 API, configure its path as script.graphql in package.json.
To serve multiple API versions, configure each version under script.api. Each version must define a GraphQL schema, GraphQL operations, or an openapi compatibility specification. When a version does not define a schema, the compiler infers its GraphQL schema from the SQRL script. See API Versioning.
A useful customization workflow is to compile without a configured schema, copy build/inferred_schema.graphqls into the project (for example, api/schema.v1.graphqls), then configure that file as the API schema.
Schema Customizationโ
If a GraphQL schema is defined, the compiler maps the object-relationship model onto the provided schema. You can write your own GraphQL schema or modify the generated GraphQL schema to control the exposed interface. Any modifications must preserve the mapping to the object-relationship model described above.
You can customize the GraphQL schema by:
- Changing field cardinalities (e.g.
[Person]!toPerson!) - Changing scalar types (e.g.
LongtoInt) - Changing the argument name for mutations (e.g.
eventtopayload) - Changing the type of fields to compatible types (e.g.
PersontoSpecificPerson) - Adding enums
- Adding interfaces and structuring types with interfaces
The compiler raises errors when the provided GraphQL schema is not compatible with the object-relationship model.
Paginationโ
Every generated query endpoint takes limit and offset arguments to page through the result (limit defaults to the configured default-limit). By default the endpoint returns the rows directly and the client tracks the offsets itself.
Set paginated-results to true in the api compiler configuration to get pagination metadata alongside the rows. The generated schema then wraps every multi-row query result in a page type:
type PersonPage {
results: [Person!]
pagination: OffsetPageInfo
}
type OffsetPageInfo {
pageSize: Int!
currentPage: Int!
totalRecords: Long!
totalPages: Int!
hasNextPage: Boolean!
hasPreviousPage: Boolean!
nextOffset: Int
prevOffset: Int
firstEventTime: DateTime
lastEventTime: DateTime
}
A query then selects the rows and the metadata it needs:
query GetPeople {
Person(limit: 10, offset: 20) {
results { name email }
pagination { currentPage hasNextPage nextOffset }
}
}
firstEventTime and lastEventTime are the earliest and latest event time of the entire result set, not of the returned page. They are null when the query result has no event time (rowtime) column.
If you provide your own GraphQL schema, pagination is opt-in per query: give the query a result type with exactly two fields โ a list of the result type, and a field of type OffsetPageInfo โ and declare OffsetPageInfo exactly as shown above. The field names of the wrapper type are up to you. The compiler validates that:
- the
OffsetPageInfotype is declared and matches the definition above - the paginated query declares both a
limitand anoffsetargument - the query returns multiple rows (i.e. it is not restricted to a single row) and is a query, not a subscription
The server computes only the metadata a request actually selects, so paginated queries cost no more than unpaginated ones unless you ask for more:
pageSize,currentPage,hasPreviousPage, andprevOffsetare derived from the request arguments and cost nothing.hasNextPageandnextOffsetmake the query fetch one extra row, which is discarded before the results are returned. Without alimitargument the page holds every remaining row andhasNextPageisfalse.firstEventTimeandlastEventTimerun a secondMIN/MAXquery over the event time column. The compiler adds an index on that column for paginated queries.totalRecordsandtotalPagesrun aCOUNT(*)over the entire result set, ignoringlimit/offset.
Selecting event times and totals together costs a single combined aggregate query, not two, so a request never makes more than one extra round trip.
totalRecords and totalPages are expensive: the COUNT(*) behind them cannot be answered from the page the request asked for and generally requires a full table scan, which gets slower as the table grows. Select them only when the client really needs an exact total, and prefer hasNextPage/nextOffset for plain "is there more?" paging.
Authoritative Modelโ
DataSQRL uses the GraphQL schema as the authoritative model for all API protocols. It is the foundation for operations, endpoints, and access patterns. This simplifies the conceptual model and server execution since any API operation maps to a GraphQL query which is executed by a centralized and optimized GraphQL engine.
The GraphQL query execution engine sits at the core of the DataSQRL server engine and executes all requests even if the GraphQL API is not exposed. This ensures uniform execution of all requests and a shared authentication and authorization mechanism for security.
The compiler.api.protocols setting controls the public API surface for every compiled version:
- Include
GRAPHQLto expose the versioned GraphQL HTTP and WebSocket endpoint, and GraphiQL when it is enabled in the server configuration. - Include
RESTand/orMCPto expose operations through those protocols. - Omit
GRAPHQLto make a REST-only, MCP-only, or REST-and-MCP-only server. The GraphQL execution engine remains internal and executes those bridge requests, but no GraphQL HTTP, WebSocket, or GraphiQL route is registered.
The default includes all three protocols.
MCP and RESTโ
DataSQRL exposes MCP and REST endpoints by converting GraphQL operations. With the default compiler.api.endpoints: "FULL", it also generates one operation for each query and mutation field in the GraphQL schema.
- Generated queries are mapped to GET REST endpoints under
rest/queries; generated mutations are mapped to POST REST endpoints underrest/mutations. For generated result sets, DataSQRL follows relationship fields up to the configuredmax-result-depthwithout loops. - When
add-prefixis enabled (the default), generated query and mutation operations are named withGetandAddprefixes respectively. MCP exposes eligible generated operations as tools.
For complete control over the exposed MCP tools and resources as well as REST endpoints, define named GraphQL queries or mutations in one or more .graphql files configured under script.operations (or a version's script.api.<version>.operations) in package.json.
The GraphQL file defining the operations contains named queries or mutations. The name of the operation is the name of the MCP tool and REST endpoint and must be unique.
Apply the @api directive to an operation to control how it is exposed:
rest:NONE,GET, orPOSTto configure the HTTP method or not expose as REST endpoint.mcp:NONE,TOOL, orRESOURCEto configure how the query is exposed in MCP.uri: AN RFC 6570 template to configure the REST path and MCP resource path. Any operation arguments that are not defined in the uri template are considered part of the payload for REST (and the method must be POST).
""" Returns up to 10 people for a given age """
query GetPersonByAge($age: Int!) @api(rest: GET, mcp: TOOL, uri: "/queries/personByAge/{age}") {
Person(age: $age, limit: 10, offset: 0) {
name
email
}
}
This defines an operation GetPersonByAge which is the name of the MCP tool and REST endpoint with the path /queries/personByAge/{age} using GET method.
The doc strings for the operations are used in the API and tooling documentation.
By default, DataSQRL adds explicit operations to the generated ones. Set compiler.api.endpoints to OPS_ONLY in the package.json to omit generated MCP and REST operations and restrict a public GraphQL endpoint to explicitly defined named operations. The server executes its stored operation definition, so clients cannot alter its selection set. Whether the GraphQL endpoint is publicly available is controlled independently by whether compiler.api.protocols includes GRAPHQL.
OpenAPIโ
OpenAPI describes the REST API derived from GraphQL operations. It is not a second API definition. For every compiled API version, DataSQRL generates an OpenAPI 3 document from operations that expose a REST endpoint. The document describes the REST endpoints only, including their paths, parameters, request bodies, and response schemas.
The generated specification is packaged as build/deploy/plan/vertx-<version>-openapi.json.
With the default Vert.x server configuration and at least one REST operation, it is served at /v1/openapi and Swagger UI is served at /v1/swagger-ui.
Set engines.vertx.config.openApiConfig.enabled to false to disable those runtime documentation endpoints.
The same configuration can customize the documentation endpoints and metadata, including endpoint, uiEndpoint, title, description, version, contact details, and license details.
Compilation still generates the specification artifact.
GraphQL and OpenAPI Configurationโ
GraphQL defines each API version, and OpenAPI is generated from that version's REST operations. The configuration determines which GraphQL schema is used and whether compilation checks the generated OpenAPI specification for backward compatibility.
The script.api.<version>.openapi field points to a previously generated OpenAPI document for that compatibility check.
It is not used as the served specification and does not define or customize the API.
To customize the API, update the GraphQL schema or the GraphQL operations that define the endpoints, as described above.
package.json configuration | GraphQL schema | OpenAPI behavior |
|---|---|---|
Neither script.graphql nor script.api | Inferred as v1 | A v1 OpenAPI artifact is generated; no compatibility check runs. |
script.graphql | Configured as v1 | A v1 OpenAPI artifact is generated; no compatibility check runs. |
script.api.<version>.schema | Configured for each version | An OpenAPI artifact is generated for each version. |
script.api.<version>.schema and .openapi | Configured for each version | The generated specification is compared with the configured prior document; compilation fails on incompatible changes. |
script.api.<version>.operations without .schema or .openapi | Inferred for that version | An OpenAPI artifact is generated; no compatibility check runs. |
script.api.<version>.openapi without .schema | Inferred for that version | The generated specification is compared with the configured prior document; compilation fails on incompatible changes. |
Use the versioned script.api form when you need OpenAPI compatibility checks. When script.api is present, it defines the API versions to compile and serve.
The top-level script.graphql and script.operations are not used for those versions.
Testingโ
DataSQRL's automated testing via the test command executes all GraphQL queries inside the configured test-folder and snapshots the returned results. Queries are executed in this order:
- All subscription queries are registered
- Mutations are executed sequentially in alphabetical order of filename. The test runner waits the configured
mutation-delay-secbetween mutations. Results are written as snapshots to the snapshot folder. - The test runner waits until the configured timeout.
- Queries are executed and results written as snapshots.
- All subscription results are sorted and written as snapshots.
If a snapshot already exists, results are compared and the test fails if they are unequal.
The test runner uses the configured headers for accessing the API. To test authentication and authorization with different access tokens, create a properties file with the same name as the GraphQL file to configure header properties per query.
For example, if your test folder contains myquery.graphql you can configure custom headers for this query in myquery.properties:
Authorization: Bearer XYZ
By default, tests use the inferred schema, even when a custom schema is configured. Set test-runner.use-inferred-schema to false in package.json to test against the configured schema instead.