Get Started
To try out hugr
, you can use the get started example provided in the examples repository on GitHub.
Prerequisites
Before you start, ensure you have the following prerequisites:
- Docker installed on your machine.
- Docker Compose installed on your machine.
- You should be familiar with GraphQL and have basic knowledge of how to work with GraphQL APIs.
- A basic understanding of how to work with databases, specifically PostgreSQL, as the example uses a PostgreSQL database.
Quick Start
Clone the repository:
git clone git@github.com:hugr-lab/examples.git
Navigate to the cloned hugr
example directory:
cd examples
Start the examples entire environment using docker-compose:
sh scripts/start.sh
This will start the hugr
server and all the necessary services. You can access the hugr
GraphQL API at http://localhost:18000/graphql
.
You can change configuration settings by modifying the example.env file or create a new .env
file in the examples
directory.
Create an example database
To create the example database, you can use the provided get-started/setup.sh
script. This script will create a new database with the sample data.
The example database is a PostgreSQL Northwind database, which is a sample database used for demonstration purposes (see README).
You can run the script with the following command:
cd get-started
sh /setup.sh
Basic Configuration
After the example database is created, you can add this database as a data source in hugr
.
The hugr data sources are added through GraphQL mutations. For the data source should be defined one or more catalogs, that contains the schema definitions for the data source. The catalogs are defined in the hugr
configuration file, which is located in the get-started/schema/
GraphQL files.
Open browser and go to http://localhost:18000/admin
(port can be changed through .env). You will see the hugr admin UI (GraphiQL).
Create a new data source with the following mutation:
mutation addNorthwindDataSet($data: data_sources_mut_input_data! = {}) {
core {
insert_data_sources(data: $data) {
name
description
as_module
disabled
path
prefix
read_only
self_defined
type
catalogs {
name
description
path
type
}
}
}
}
Add variables:
{
"data": {
"name": "northwind",
"type": "postgres",
"prefix": "nw",
"description": "The Northwind database example",
"read_only": false,
"as_module": true,
"path": "postgres://hugr:hugr_password@postgres:5432/northwind",
"catalogs": [
{
"name": "northwind",
"type": "uri",
"description": "Northwind database schema",
"path": "/workspace/get-started/schema"
}
]
}
}
Here we add a new data source named northwind
, which is a PostgreSQL database. The path
field contains the connection string to the database, and the catalogs
field contains the path to the schema definitions.
We define the data source as a module (as_module: true
), which means that all queries, mutations and functions will be placed in general GraphQL schema inside the separate object and attached in field with the name of the data source (in this case northwind
) in base schema fields (query
, mutation
, function
).
After it you can load the data source manually by running the following mutation:
mutation loadNorthwindDataSource {
function {
core {
load_data_source(name: "northwind") {
success
message
}
}
}
}
By default, the data source is loaded automatically when the hugr
server starts. However, you can also load or reload (if the schema definitions was changed) it manually using the above mutation.
After the data source is loaded, you can explore see the generated GraphQL queries, mutations and functions in the hugr
admin UI (GraphiQL) at http://localhost:18000/admin
. The following query will return the query and mutation types of the generated schema:
{
__schema {
queryType {
...type_def
}
mutationType{
...type_def
}
}
}
fragment type_def on __Type {
kind
name
fields {
name
description
type {
name
description
ofType {
name
description
}
}
}
}
It returns the schema definition for the query and mutation types, including the fields and their types. You can use this query to explore the schema and see what queries and mutations are available.
{
"data": {
"__schema": {
"mutationType": {
"fields": [
{
"description": "The root query object of the module core",
"name": "core",
"type": {
"description": "The root query object of the module core",
"name": "core_mutation",
"ofType": null
}
},
{
"description": "The root query object of the module northwind",
"name": "northwind",
"type": {
"description": "The root query object of the module northwind",
"name": "northwind_mutation",
"ofType": null
}
},
{
"description": "Functions",
"name": "function",
"type": {
"description": "The root function mutation object of the module",
"name": "MutationFunction",
"ofType": null
}
}
],
"kind": "OBJECT",
"name": "Mutation"
},
"queryType": {
"fields": [
{
"description": "The root query object of the module core",
"name": "core",
"type": {
"description": "The root query object of the module core",
"name": "core_query",
"ofType": null
}
},
{
"description": "Performs jq query on result set\nResults will be placed in 'extension' field by field or alias name",
"name": "jq",
"type": {
"description": "",
"name": "Query",
"ofType": null
}
},
{
"description": "The root query object of the module northwind",
"name": "northwind",
"type": {
"description": "The root query object of the module northwind",
"name": "northwind_query",
"ofType": null
}
},
{
"description": "Functions",
"name": "function",
"type": {
"description": "The root function object of the module",
"name": "Function",
"ofType": null
}
}
],
"kind": "OBJECT",
"name": "Query"
}
}
}
}
If you add the data source as not a module (i.e. as_module: false
), the queries, mutations and functions will be added to the root base schema directly. But to avoid name conflicts with other data sources, it is recommended to set the prefix for the data source (i.e. prefix: "nw"
). In this case, the queries, mutations and functions will be added to the root base schema with the prefix nw_
, for example nw_customers
, nw_orders
, etc.
Your First Schema
This example uses the Northwind database, which contains a variety of tables such as customers
, orders
, products
, etc. You can explore the schema definitions in the get-started/schema
directory.
You can create schema definitions by yourself or use the provided schema definitions in the get-started/schema
directory. The schema definitions are written in GraphQL SDL (Schema Definition Language) and can be used to define the structure of your data.
To create your first schema, create the new GraphQL file in the get-started/schema
directory, for example my_northwind_schema.graphql
. You can define your schema using the GraphQL SDL syntax. Open the file and add the following content
Customers tables schema
# Customers
type customers @table(name: "customers") {
id: String! @pk @field_source(field: "customer_id")
company_name: String!
contact_name: String!
contact_title: String
address: String
city: String
region: String
postal_code: String
country: String
phone: String
fax: String
}
# Customers Demo
type customers_types @table(name: "customer_demographics") {
id: String! @pk @field_source(field: "customer_type_id")
description: String! @field_source(field: "customer_desc")
}
# Customers Linked Types
type customers_linked_types @table(name: "customer_customer_demo", is_m2m: true) {
customer_id: String! @pk @field_references(
name: "customers_linked_types_customer_id"
references_name: "customers"
field: "id"
query: "customer"
description: "Customer"
references_query: "types"
references_description: "Linked customer types"
)
type_id: String! @pk @field_source(field: "customer_type_id")
@field_references(
name: "customers_linked_types_type_id"
references_name: "customers_types"
field: "id"
query: "type"
description: "Customer type"
references_query: "customers"
references_description: "Linked customers"
)
}
Here we describe the customers
table (directive @table
), which contains information about customers. We will use id in GraphQL schema as the primary key instead customer_id
(directives @pk
and @field_source
are used for this purpose).
Than we describe the customer_types
table (base on customer_demographics
database table), which contains information about customer types. We will use id in GraphQL schema as the primary key instead customer_type_id
.
Finally, we describe the customer_customer_demo
table, which is a many-to-many relationship between customers and customer types. We use the @field_references
directive to define the relationship between the tables, as well as REFERENCES
SQL clause for the field at table creation. Arguments for the @field_references
directive:
name
: the name of the relation in the GraphQL schemareferences_name
: the name of the referenced data object in the GraphQL schemafield
: the field in the referencing table that references to the current fieldquery
: the name of the query field that will be added to this table in the GraphQL schemadescription
: a description for the query fieldreferences_query
: the name of the query field that will be added to the referenced table in the GraphQL schemareferences_description
: a description for the query field in the referenced table
You can add relations between tables by many fields using the @references
directive for the table type. (See details in the Query Engine Configuration/Schema Definition guide).
If we load this schema, we will see generated queries and mutations for the customers
table, as well as for the customers_types
and customers_linked_types
tables. For each table the following queries and mutations will be generated in the module northwind
:
- query
customers_by_pk
: query to get a customer by id (primary key), with required argumentid
- query
customers
: query to get all customers, with optional argumentsfilter
,distinct_on
,order_by
,limit
, andoffset
- query
customers_aggregation
: query to get aggregated data for customers with optional argumentsfilter
,distinct_on
,order_by
,limit
, andoffset
- query
customers_bucket_aggregation
: query to get bucket aggregated data for customers with optional argumentsfilter
,distinct_on
,order_by
,limit
, andoffset
- mutation
insert_customers
: mutation to insert a new customer,with required argumentdata
of typenw_customers_mut_input_data!
- mutation
update_customers
: mutation to update an existing customer, with required argumentdata
of typenw_customers_mut_data!
and optional argumentfilter
to filter the customers to update - mutation
delete_customers
: mutation to delete a customer, with optional argumentfilter
to filter the customers to delete.
To filter the data, you can use the filter
argument in the queries. For example, to get all customers from the USA, you can use the following query:
query MyQuery {
northwind {
customers(filter: {country: {eq: "Germany"}}) {
country
city
company_name
}
}
}
You can also use the distinct_on
argument to get distinct values for a field, and the order_by
argument to order the results by a field. For example, to get all customers from the USA ordered by city, you can use the following query:
query MyQuery {
northwind {
customers(order_by: [
{field: "country", direction: ASC}
]) {
country
city
company_name
}
}
}
The filter input object generated for the customers
table will contain all fields of the table, so you can filter by any field. The eq
operator is used to filter by equality, but you can also use other operators like ne
, gt
, lt
, etc, it depends on the field type. As well to the filter
will be added references objects based on the @field_references
directive references type (many-to-many, one-to-many, many-to-one).
As well the subquery fields will be added to the customers
table type, so you can get the linked customer types for each customer, all subqueries will be added with their filter, distinct, order_by, limit, offset arguments.
The input data for the insert_customers
mutation will contain all fields of the customers
table, so you can insert a new customer with field values. As well the input data type also contains the references objects based on the @field_references
directive references type, so you can insert a new customer with linked customer types, that will be created at the same time as the customer. For m2m relations, the input data will contain a field for the linked objects (customers types), and if they will be passed in the insert_customers
mutation, they will be created and linked to the customer. If you want to link existing customer types, you should run separate insert_customers_linked_types
mutation with the customer id and customer type id.
Aggregation query will include _rows_count
field and fields for each field of the customers
table with aggregation functions like sum
, avg
, min
, max
, etc, based on field types. For example, to get the number of customers and list of distinct countries, you can use the following query:
query aggCustomers {
northwind{
customers_aggregation{
_rows_count
country{
list(distinct: true)
}
}
}
}
Bucket aggregation query allows you to group the data by a field and get aggregated data for each group. For example, to get the number of customers grouped by country, you can use the following query:
query bucketAggCustomers {
northwind{
customers_bucket_aggregation{
key{
country
}
aggregations{
_rows_count
}
}
}
}
You can add filter to each aggregation query, for example, to get the number of all customers and customers, thats names contains 'am' by countries, you can use the following query:
query bucketAggCustomers {
northwind {
customers_bucket_aggregation (order_by: [
{field: "am._rows_count"}
]){
key {
country
}
aggregations {
_rows_count
}
am: aggregations(
filter: {contact_name: {ilike: "%am%"}}
) {
_rows_count
}
}
}
}
Employees tables schema
Let's add the employees
table schema, which contains information about employees:
# Employees
type employees @table(name: "employees") {
id: Int! @pk @field_source(field: "employee_id")
last_name: String!
first_name: String!
title: String
title_of_courtesy: String
birth_date: String
hire_date: String
address: String
city: String
region: String
postal_code: String
country: String
home_phone: String
extension: String
photo: String
notes: String
reports_to: Int @field_references(
name: "employees_reports_to"
references_name: "employees"
field: "id"
query: "reportsTo"
description: "Reports to employee"
references_query: "employees"
references_description: "Employees reporting to this employee"
)
}
# Regions
type regions @table(name: "regions") {
id: Int! @pk @field_source(field: "region_id")
description: String!
}
# Territories
type territories @table(name: "territories") {
id: Int! @pk @field_source(field: "territory_id")
name: String!
region_id: Int! @field_references(
name: "territories_region_id"
references_name: "regions"
field: "id"
query: "region"
description: "Region"
references_query: "territories"
references_description: "Territories in this region"
)
}
# Employee territories
type employee_territories @table(name: "employee_territories", is_m2m: true) {
employee_id: Int! @pk @field_references(
name: "employee_territories_employee_id"
references_name: "employees"
field: "id"
query: "employee"
description: "Employee"
references_query: "territories"
references_description: "Territories assigned to this employee"
)
territory_id: Int! @pk @field_source(field: "territory_id")
@field_references(
name: "employee_territories_territory_id"
references_name: "territories"
field: "id"
query: "territory"
description: "Territory"
references_query: "employees"
references_description: "Employees assigned to this territory"
)
}
Products tables schema
# Suppliers
type suppliers @table(name: "suppliers") {
id: Int! @pk @field_source(field: "supplier_id")
company_name: String!
contact_name: String
contact_title: String
address: String
city: String
region: String
postal_code: String
country: String
phone: String
fax: String
}
# Products categories
type categories @table(name: "categories") {
id: Int! @pk @field_source(field: "category_id")
name: String! @field_source(field: "category_name")
description: String
picture: String
}
# Products
type products @table(name: "products") {
id: Int! @pk @field_source(field: "product_id")
name: String! @field_source(field: "product_name")
supplier_id: Int! @field_references(
name: "products_supplier_id"
references_name: "suppliers"
field: "id"
query: "supplier"
description: "Supplier"
references_query: "products"
references_description: "Supplied products"
)
category_id: Int! @field_references(
name: "products_category_id"
references_name: "categories"
field: "id"
query: "category"
description: "Category"
references_query: "products"
references_description: "Products"
)
quantity_per_unit: String
unit_price: Float
units_in_stock: Int
units_on_order: Int
reorder_level: Int
discontinued: Boolean!
}
Orders tables schema
# Shippers
type shippers @table(name: "shippers") {
id: Int! @pk @field_source(field: "shipper_id")
company_name: String!
phone: String
}
# Orders
type orders @table(name: "orders") {
id: Int! @pk @field_source(field: "order_id")
customer_id: String! @field_references(
name: "orders_customer_id"
references_name: "customers"
field: "id"
query: "customer"
description: "Customer"
references_query: "orders"
references_description: "Orders placed by this customer"
)
employee_id: Int! @field_references(
name: "orders_employee_id"
references_name: "employees"
field: "id"
query: "employee"
description: "Employee"
references_query: "orders"
references_description: "Orders handled by this employee"
)
order_date: Timestamp
required_date: Timestamp
shipped_date: Timestamp
ship_via: Int @field_references(
name: "orders_ship_via"
references_name: "shippers"
field: "id"
query: "shipper"
description: "Shipper"
references_query: "orders"
references_description: "Orders shipped by this shipper"
)
freight: Float
ship_name: String
ship_address: String
ship_city: String
ship_region: String
ship_postal_code: String
ship_country: String
}
# Order details
type order_details @table(name: "order_details") {
order_id: Int! @pk @field_references(
name: "order_details_order_id"
references_name: "orders"
field: "id"
query: "order"
description: "Order"
references_query: "details"
references_description: "Details of this order"
)
product_id: Int! @pk @field_references(
name: "order_details_product_id"
references_name: "products"
field: "id"
query: "product"
description: "Product"
references_query: "orders"
references_description: "Details of this product in the order"
)
unit_price: Float!
quantity: Int!
discount: Float!
# Calculated total amount of ordered product
total: Float! @sql(exp: "round(([unit_price] * [quantity] * (1 - [discount]))*100)/100")
}
Here we describe the order_details table, which contains information about the products in the order. We use the @field_references
directive to define the relationship between the tables, as well as REFERENCES
SQL clause for the field at table creation. The total
field is calculated using the SQL expression, which calculates the total amount of ordered product based on the unit price, quantity and discount.
Create orders view
We can define a view for the tables using the @view
directive. For example, we can create a view that contains information about the orders, customers, employees, and products in the order:
# Shipped products view
type shipped_products_view
@view(
name: "shipped_products"
sql: """
SELECT
orders.customer_id,
orders.employee_id,
orders.order_date,
orders.shipped_date,
orders.ship_via,
order_details.product_id,
order_details.unit_price,
order_details.quantity,
order_details.discount,
products.supplier_id,
products.category_id
FROM orders
INNER JOIN order_details ON orders.order_id = order_details.order_id
INNER JOIN products ON order_details.product_id = products.product_id
WHERE orders.shipped_date IS NOT NULL
"""
)
{
customer_id: String! @field_references(
name: "shipped_products_view_customer_id"
references_name: "customers"
field: "id"
query: "customer"
description: "Customer"
references_query: "shippedProducts"
references_description: "Shipped products for this customer"
)
employee_id: Int! @field_references(
name: "shipped_products_view_employee_id"
references_name: "employees"
field: "id"
query: "employee"
description: "Employee"
references_query: "shippedProducts"
references_description: "Shipped products handled by this employee"
)
order_date: Timestamp
shipped_date: Timestamp
ship_via: Int @field_references(
name: "shipped_products_view_ship_via"
references_name: "shippers"
field: "id"
query: "shipper"
description: "Shipper"
references_query: "shippedProducts"
references_description: "Shipped products by this shipper"
)
product_id: Int! @field_references(
name: "shipped_products_view_product_id"
references_name: "products"
field: "id"
query: "product"
description: "Product"
references_query: "shippedProducts"
references_description: "Shipped product details in this order"
)
unit_price: Float!
quantity: Int!
discount: Float!
supplier_id: Int! @field_references(
name: "shipped_products_view_supplier_id"
references_name: "suppliers"
field: "id"
query: "supplier"
description: "Supplier of the product in the order details view."
)
category_id: Int! @field_references(
name: "shipped_products_view_category_id"
references_name: "categories"
field: "id"
query: "category"
description:
"Category of the product in the order details view."
)
total: Float! @sql(exp: "round(([unit_price] * [quantity] * (1 - [discount]))*100)/100")
}
You can query it:
query shippedAgg {
northwind{
shipped_products_view_bucket_aggregation{
key{
category{
name
}
customer{
company_name
}
}
aggregations{
_rows_count
total{
sum
}
quantity{
sum
}
}
}
}
}
This query will return the number of shipped products grouped by category and customer, as well as the total amount and quantity of shipped products for each group.
By the view will be generated the following queries in the module northwind
:
- query
shipped_products_view
: query to get all shipped products, with optional argumentsfilter
,distinct_on
,order_by
,limit
, andoffset
- query
shipped_products_view_aggregation
: query to get aggregated data for shipped products with optional argumentsfilter
,distinct_on
,order_by
,limit
, andoffset
- query
shipped_products_view_bucket_aggregation
: query to get bucket aggregated data for shipped products with optional argumentsfilter
,distinct_on
,order_by
,limit
, andoffset
As well this queries will be added to the references objects in the customers
, employees
, products
, shippers
and categories
tables, so you can get the shipped products for each customer, employee, product, shipper and category.
The views supports parameterization
, so you can use the @args
directive to define the input type that will use as parameters for the view. The input parameters can be used in the SQL query of the view by name, for example: [$requested_time]
.
Some example queries
You can use the following queries to explore the data in the Northwind database:
Get the list of customers with their sum of orders from the Northwind database
{
northwind {
customers {
id
company_name
orders_aggregation{
details{
total{
sum
}
}
}
}
}
}
Get the total amount and products count by category and shipper
{
northwind {
order_details_bucket_aggregation(
order_by:[
{field: "aggregations.total.sum", direction: DESC}
]
){
key{
product{
category{
name
}
}
order{
shipper{
company_name
}
}
}
aggregations{
total{
sum
}
quantity{
sum
}
}
}
}
}
Get the total shipped products (amount) by years and months
{
northwind {
orders_bucket_aggregation(
filter: {
shipped_date: {
is_null: false
}
}
order_by: [
{field: "key.year", direction: DESC}
{field: "key.month", direction: DESC}
]
){
key{
year: _shipped_date_part(extract: year)
month:_shipped_date_part(extract: month)
}
aggregations{
_rows_count
details{
total{
sum
}
}
}
}
}
}
Get the total shipped products (amount) by month bucket and in the orders were shipped by suppliers from Finland and France
{
northwind {
orders_bucket_aggregation(
filter: {
shipped_date: {
is_null: false
}
details: {
any_of: {
product:{
supplier:{
country: {in: ["Finland", "France"]}
}
}
}
}
}
order_by: [
{field: "key.bucket", direction: DESC}
]
){
key{
bucket: shipped_date(bucket: month)
}
aggregations{
_rows_count
details{
total{
sum
}
}
}
}
}
}
Get the total shipped products (amount) by month bucket and shipped by suppliers from Finland and France
{
northwind {
orders_bucket_aggregation(
filter: {
shipped_date: {
is_null: false
}
}
order_by: [
{field: "key.bucket", direction: DESC}
]
){
key{
bucket: shipped_date(bucket: month)
}
aggregations{
_rows_count
details(
filter:{
product:{
supplier:{
country: {in: ["Finland", "France"]}
}
}
}
){
total{
sum
}
}
}
}
}
}
Advanced features
The hugr
supports a lot of advanced features, such as:
- Joins: You can define joins between tables using the
@join
directive. The joins can be defined as many-to-many, one-to-many, or many-to-one relationships. The joins will be added to the GraphQL schema as subqueries, so you can query the related data in a single query. - Geometry types: You can use the scalar type
geometry
to represent geometric fields (Postgis for PostgreSQL, and spatial for DuckDB). TheGeometry
fields has their own filter operators and aggregation functions. - Json types: You can use the scalar type
JSON
to represent JSON fields (PostgreSQLjsonb
, DuckDBjson
). it will allow you to store and query JSON data in the database. TheJSON
fields has their own filter operators and aggregation functions. - Nested types: You can define fields that contains nested objects or arrays of objects. The nested objects can be defined as normal GraphQL types and will be accessible to query, aggregate and filter. (For PostgreSQL it should be defined as
jsonb
, for DuckDB asstruct
). - Functions: You can define custom functions in the schema, which can be used to perform complex operations on the data. The functions can be defined using the
@function
directive and can be used in the queries and mutations. - Query time joins: Each data object contains a
_join
field, which can be used to join the data with other data objects. The_join
field can be used to join the data with other data objects in the same data source or in other data sources. - Spatial queries: Each data object that has
Geometry
fields, contains a_spatial
field. The spatial queries can be used to filter the data by distance, intersection, containment, etc. - JQ transformations: You can use the
jq
query language to transform the query results. Thejq
query can be used in thejq
query field, which will return the transformed data in theextension
field. - HTTP data sources: You can define HTTP data sources, that will allow you to query data from external APIs. The API could be secured with JWT or API key, and the data can be transformed using the
jq
query language. - Extensions: You can extend the general schema data types, to add cross data sources subqueries (joins) and function calls.
- Field-level permissions: You can define permissions for each field in the view, so you can control who can access the data.
- Caching: You can enable caching for the view, so you can improve performance by reducing the number of database queries.
Next steps
You can continue with the GraphQL Operations: Queries & Mutations guide to learn more about how to interact with your data using GraphQL.