Skip to content

Define the domain

The application provides the QLiveDomain bean the rest of QLive reads its schema from. What goes into it is described in Defining the GraphQL Domain; this page is how to build it.

We will follow the example code in qlive-test here. The QLiveDomain bean is created in QLiveDomainConfiguration

The bean creation is split into two methods so that it can be reused for tests.

QLiveDomainConfiguration
@Configuration
public class QLiveDomainConfiguration {
@Bean
public QLiveDomain qLiveDomain() throws IOException {
return newDomain(
dslContext,
applicationContext.getBeansWithAnnotation(QLiveLogic.class).values(),
applicationContext.getBeansOfType(MetadataProvider.class).values()
);
}
static QLiveDomain newDomain(
DSLContext dslContext,
Collection<Object> logicBeans,
Collection<MetadataProvider> metadataProviders
) throws IOException {
return QLiveDefaultDomain.newDomain(dslContext, metadataProviders)
// ...
.build();
}
}

qLiveDomain is the bean creation method that looks up all beans annotated with @QLiveLogic and all beans implementing MetadataProvider.

newDomain is the static creation method that gets called from qLiveDomain and it has package access to be accessed by tests within the same package.

QLiveDefaultDomain.newDomain(dslContext, metadataProviders) creates the domain builder that can be configured for the application and then be built by calling the final .build(). It standardizes the QLive scalars (QueryConfig, Condition, FieldExpression, ComputedValue and the rest) into the domain.

QLiveDomain is the whole of what the framework and your own code read the domain through: getGraphQLSchema(), getTypeRegistry() and getMetaData(). Declare the bean as that rather than as the QLiveDomainImpl the builder returns – the schema assembly behind it is not something an application has anything to say to.

To include the POJOs generated by jOOQ in our domain, we just have to import the database schema we want, most often Public.PUBLIC.

Including the jOOQ schema in the QLiveDomain
import io.github.qlivedev.qlivetest.domain.Public;
@Configuration
public class QLiveDomainConfiguration {
static QLiveDomain newDomain(
DSLContext dslContext,
Collection<Object> logicBeans,
Collection<MetadataProvider> metadataProviders
) throws IOException {
return QLiveDefaultDomain.newDomain(dslContext, metadataProviders)
.objectTypes(Public.PUBLIC)
// ...
.build();
}
}

Of course, the class name of your Public will be different. It is created in the root of where your jOOQ codegen configuration puts all classes.

Including logic beans in the QLiveDomain
@Configuration
public class QLiveDomainConfiguration {
static QLiveDomain newDomain(
DSLContext dslContext,
Collection<Object> logicBeans,
Collection<MetadataProvider> metadataProviders
) throws IOException {
return QLiveDefaultDomain.newDomain(dslContext, metadataProviders)
.logicBeans(logicBeans)
// ...
.build();
}
}

A relation connects two domain types. It defines what GraphQL fields result of the relation on each side of the relation and it can define the name of those fields.

Defining relations
import static io.github.qlivedev.qlivetest.domain.Tables.FOO;
@Configuration
public class QLiveDomainConfiguration {
static QLiveDomain newDomain(
DSLContext dslContext,
Collection<Object> logicBeans,
Collection<MetadataProvider> metadataProviders
) throws IOException {
return QLiveDefaultDomain.newDomain(dslContext, metadataProviders)
.configureRelation(FOO.OWNER_ID, SourceField.OBJECT_AND_SCALAR, TargetField.MANY)
// ...
.build();
}
}

Here we import the jOOQ tables we are going to use as static imports.

The relation definition is based on the a field inside the TableImpls generated by jOOQ.

The SourceField enum defines what fields to create for the source side / the side the foreign key field is defined.

  • NONE - No field
  • SCALAR - Just the scalar field
  • OBJECT - Just the object field as embedded type
  • OBJECT_AND_SCALAR - Both the scalar field and the embedded type field.

TargetField defines what field to create on the target side / the side the foreign key points to.

  • NONE - No field
  • ONE - An embedded object. The relation is assumed to be a to-one relation
  • MANY - An embedded array. The relation is assumed to be a to-many relation

Here Foo.ownerId creates a field for both Foo.ownerId and Foo.owner. The relation points to AppUser who receives a foos array containing all foos that belong to that user.

If you don’t define an embedded object, it will receive its name from the key field name minus “Id”. If you don’t define the name for an embedded array, QLive will use a pluralization function to create the plural of your relation field name without “Id”. By default QLive uses evo-inflector, an abstract rule-based English pluralization function.

A many-to-many relation lives in a link table with a foreign key to each side. In qlive-test, bar_link associates bars with bazes:

BarLink in schema.graphql
"Generated from public.bar_link"
type BarLink {
"DB column 'bar_id'"
barId: String!
"DB column 'baz_id'"
bazId: String!
"DB column 'id'"
id: String!
"DB column 'version'"
version: String
}

Declare it with configureManyToMany(), naming the two foreign key fields and the field each side gets:

Defining a many-to-many relation
import static io.github.qlivedev.qlivetest.domain.Tables.BAR_LINK;
@Configuration
public class QLiveDomainConfiguration {
static QLiveDomain newDomain(
DSLContext dslContext,
Collection<Object> logicBeans,
Collection<MetadataProvider> metadataProviders
) throws IOException {
return QLiveDefaultDomain.newDomain(dslContext, metadataProviders)
.configureManyToMany(BAR_LINK.BAR_ID, BAR_LINK.BAZ_ID, "bazes", "bars")
// ...
.build();
}
}

Bar gets a field bazes listing its bazes, and Baz a field bars listing its bars. Both are fetched through bar_link:

Bar in schema.graphql
type Bar {
"Many-to-many objects through bar_link.bar_id"
bazes: [Baz]!
# ...
}

Pass null for a side that should get no field.

Each foreign key has to point to the id of its side. Beyond the two foreign keys, the link table needs nothing: an id and a version column are fine, and QLive fills both in when it writes a link, but neither is required. A composite primary key over (bar_id, baz_id) works as well. Do give the link table a unique key over its two foreign keys. Without one, QLive logs a warning at startup, because two users adding the same association at the same moment could create it twice.

How to change which bazes a bar has is in Edit rows with a working set.

If the link table has a required column of its own – NOT NULL without a default, a role say – the two fields are read-only, since QLive cannot insert a link row from the two foreign keys alone. Such a link is an entity of its own. Configure its foreign keys as ordinary relations with configureRelation() and edit the link rows as rows. A table can have both: the many-to-many declaration leaves relations over the same foreign keys alone.

A link table without foreign key constraints is declared by naming the fields of its type, the same way as a relation without a foreign key:

.withManyToMany(
new ManyToManyBuilder()
.withPojoFields(BarLink.class, "barId", Bar.class, "bazId", Baz.class)
.withFieldNames("bazes", "bars")
)

Without a foreign key backing the relation, you need to use the alternate, longer configuration:

.withRelation(
new RelationBuilder()
.withPojoFields(
Waldo.class,
Collections.singletonList("userId"),
AppUser.class,
Collections.singletonList("id")
)
.withSourceField(SourceField.OBJECT_AND_SCALAR)
.withLeftSideObjectName("user")
.withTargetField(TargetField.MANY)
.withRightSideObjectName("waldos")
)

.withPojoFields(), defines the source type, the virtual foreign key field(s), the target type and the field(s) the virtual foreign key is pointing to. The rest configures the fields as the shorter method, only with individual “withers”.

Every MetadataProvider bean the context holds is handed to the domain by qLiveDomain. Each one writes into the DomainMeta the server embeds in the page, on three levels: an addendum next to types, genericTypes, relations and manyToMany, type metadata, and field metadata on individual fields.

The client-side counterpart is declaration merging – name your addenda once and they are typed everywhere the application reads config().meta:

declare module "@qlivedev/qlive-ts" {
interface DomainMeta {
quickSearchTypes: string[]
}
interface DomainFieldMeta {
quickSearch?: boolean
}
}

Nothing but a test on the Java side notices when the two drift apart, so it is worth having one.

QueryConfigMetadataProvider is the one QLive brings. It writes the page size, the sort and the standing condition a row type gets when nothing names one:

@Bean
public MetadataProvider queryConfigMetadata()
{
return QueryConfigMetadataProvider.newProvider()
.forAllTypes()
.pageSize(20)
.andForType(Foo.class)
.sortFields("name")
.maxPageSize(100)
.build();
}

forAllTypes() is the house rule for every row type the domain has a query document for; forType(Class) and forTypes(Class...) are the departures from it. Chain statements with andForType(), andForTypes() and andForAllTypes(), and close the chain with build(). A type may only be named once – say it with forAllTypes() and depart from it per type rather than declaring the same type twice.

A page size of 0 asks for every row there is, and a config comes from a browser. maxPageSize is the ceiling.

Every query over rows of that type is held to it, whoever asked and however the config got there – an injection, an update(), a logic bean building one by hand. A larger page, and an unlimited one, become that page size. A type that declares none is unlimited, which is what every type is until an application says otherwise.

The config that comes back on the document says the page size that was applied, so a client that hit the maximum sees it: rowCount is still everything the condition matches, and a cut page does not look like the last page of a short table. The maximum itself travels to the client as type meta data under maxPageSize, for the page size controls that would rather not offer what the server will not give.

Nothing in newDomain touches the DSLContext until a query executes, so a test can build the same schema with null and assert on it without a database. Hand it the same logic beans and metadata providers, or you are testing a different schema than you ship.