Using the search API
The search API serves GraphQL at http://localhost:4000/graphql: the read side of the stack that Running the search API brings up. Have that running before you try anything here – every example below queries your own machine.
A first query:
{
creativeWorks(query: "Maastricht", perPage: 10) {
items {
name {
value
}
dataset {
id
name {
value
}
}
associatedMedia {
thumbnailUrl
contentUrl
license
}
}
}
}Each reproduction of a work is one associatedMedia entry, carrying its own URLs, license and attribution, so an object with several images keeps each image’s values together. A work that also publishes a IIIF Presentation manifest exposes it as iiifManifest – one field, however the publisher states it, so a IIIF-aware client dereferences that and never has to read associatedMedia to find the manifest or to know which version of the profile a collection follows. hasMedia filters down to works with a displayable image.
dataset is the collection a result came from – a fact about how it was indexed, and the one carried by every entity type that has no containing-collection property of its own: persons, organizations, terms and occupations. places are the exception, for a reason worth knowing: most of them are shared across datasets rather than drawn from one, so a single value would name whichever dataset happened to be indexed last. See A place is one place. Query the datasets collection by that id for the rest of the register’s description: publisher, license, description and landingPage. (A reference serves the referenced collection’s own fields – and every collection serves its display field as name, so dataset { name { value } } reads the same as a creator or a material reference. A dataset’s name is its dcterms:title in the register; the profile a value came from never changes the word you ask for.) (isPartOf is a different thing – what the publisher claims the work is part of. Where that is just the dataset it was harvested from it is left out, so it names only the other datasets a work belongs to, and is usually absent.)
The playground at http://localhost:4000/graphql documents every collection, field and filter, and is always up-to-date. What follows is the handful of shapes worth knowing before you go exploring. Each is a link that opens the query already loaded.
Eight collections are served: creativeWorks, persons, organizations, places, terms, occupations, datasets and publishers. Every one takes the same five arguments – query, where, orderBy, page, perPage – and returns items, pagination and facets.
Filtering on a date before 1 CE needs the expanded form. A bound is parsed with JavaScript's Date, which reads a leading minus as a UTC offset unless the year has exactly six digits – so dateCreated: {min: "-1100"} silently means 1099 CE and matches nothing, while min: "-001100-01-01T00:00:00.000Z" works. That is also the shape the API returns, so round-tripping a value you read back is always safe. Upstream fix at ldelements/lde#725; until it lands, pad the year.
perPage runs from 0 to 100; page through page for more. Asking for more than 100 currently fails with a bare “Unexpected error.” rather than saying what is wrong, so check this first if a query stops working when you raise the page size (ldelements/lde#730).
Browse without searching
You get pagination.total and every facet bucket, and items comes back empty. Nothing is fetched or hydrated, so it is the cheap query to run on page load, before the user has typed anything, to populate a filter UI.
{
creativeWorks(perPage: 0) {
# 0 is deliberate: it asks for total count and facets without the documents
pagination {
total
}
facets {
material {
value
label {
value
}
count
}
hasMedia {
value
count
}
}
}
}Each bucket carries the value to filter on and the count to show beside it. label is resolved only where the field points at another collection – material here, whose labels come from terms – so a facet over a plain keyword field such as type or license returns label: null, and the interface falls back to the value itself.
Filter
Keys inside where are combined with AND, and each takes a list that is itself an OR. Ask for banners that are made of silk or velvet, and have an image:
{
creativeWorks(
perPage: 10
where: {
hasMedia: true
material: {
in: [
"https://id.drapo.nl/2d744075-7952-5e8a-ad0e-0cd664102d6c"
"https://id.drapo.nl/a472ec0b-957c-57a9-8178-b70fdab1c902"
]
}
}
) {
pagination {
total
}
items {
name {
value
}
}
}
}Combine fields with or
To OR across different fields, list the alternatives under or. Each entry is a criterion: the same field names as where, without further nesting. This asks for banners that either depict a coat of arms or are made of silk:
{
creativeWorks(
perPage: 10
where: {
or: [
{
about: {
in: ["https://id.drapo.nl/a005b7a1-562a-5d71-83c2-b26dd96dfae6"]
}
}
{
material: {
in: ["https://id.drapo.nl/2d744075-7952-5e8a-ad0e-0cd664102d6c"]
}
}
]
}
) {
pagination {
total
}
}
}253 works carry that subject and 123 that material; the union is 341, because 35 have both. or sits alongside ordinary keys, so adding hasMedia: true next to it narrows the union rather than joining it, to 340.
To ask “what refers to this object, through any field?”, generate that list of criteria instead of writing it out: What refers to an object reads every field that can hold an IRI out of the schema itself.
Group with and
Most combinations need no grouping. Ordinary keys already AND, and each field's in already ORs, so “(silk or velvet) and depicting a coat of arms” is just two keys. Reach for and only when both sides of the AND are themselves cross-field ORs, which nothing else can express.
and takes whole where objects rather than criteria, so each entry may carry its own or:
{
creativeWorks(
perPage: 0
where: {
and: [
{
or: [
{
about: {
in: ["https://id.drapo.nl/a005b7a1-562a-5d71-83c2-b26dd96dfae6"]
}
}
{
material: {
in: ["https://id.drapo.nl/2d744075-7952-5e8a-ad0e-0cd664102d6c"]
}
}
]
}
{
or: [
{
about: {
in: ["https://id.drapo.nl/f5ca916a-fab2-5cdd-a0c7-e7105856fcd5"]
}
}
{
material: {
in: ["https://id.drapo.nl/a472ec0b-957c-57a9-8178-b70fdab1c902"]
}
}
]
}
]
}
) {
pagination {
total
}
}
}The first group matches 341 works and the second 554; their intersection is 221.
Dataset details
A detail view usually has to say what dataset an object came from and on what terms: the license to honour, the institution to credit, and somewhere to send a visitor for the full record. A work carries the dataset it was indexed from, but only as a reference: an id and a label.
{
creativeWorks(query: "vaandel", perPage: 1) {
items {
id
name {
value
}
dataset {
id
}
}
}
}The terms live on the dataset, so take that id and ask the datasets collection:
{
datasets(
where: { id: { in: ["https://id.drapo.nl/dataset/drapo-schemaorg"] } }
) {
items {
name {
value
language
}
license
landingPage
publisher {
id
name {
value
}
}
}
}
}Two round trips, because there is no join yet, but only two for a whole page of results, not one per object: collect the distinct dataset ids from the page and pass them together, since a page of results usually shares a handful of datasets between them.
dataset and isPartOf both resolve against datasets, and they are not the same thing. dataset is a fact about the harvest: the dataset this document was drawn from, which is how the index knows it at all. isPartOf is a claim the publisher makes about the work, and a work may assert several. For a work drawn from the dataset it says it belongs to the two coincide – for any other value they do not, and only dataset tells you where the record came from.
The same concept in two datasets
A Term is a local node: its id is minted by the dataset that published it, so two datasets describing the same concept give you two terms with two ids and two labels. What they share is sameAs – the URI of the term in a controlled vocabulary – and that is what to compare on, not the term's own id.
So matching a concept across datasets is two queries. Find the terms first:
{
terms(where: { sameAs: { in: ["https://sws.geonames.org/2751306/"] } }) {
items {
id
name {
value
}
dataset {
id
}
}
}
}Then filter works by the ids you got back – all of them, since each dataset has its own:
{
creativeWorks(
perPage: 0
where: {
about: {
in: ["https://id.drapo.nl/2d744075-7952-5e8a-ad0e-0cd664102d6c"]
}
}
) {
pagination {
total
}
}
}A term with no sameAs cannot be matched this way, and deliberately so: nothing in the data says two similarly named local terms are the same concept, and the search API does not guess.
Places do not work this way, and that is the point of the next section: they are already keyed on the term they align to, so one place is one document and one id.
Place references arrive through their own fields – locationCreated and contentLocation on a work, location on an organization, birthPlace and deathPlace on a person – and resolve against places, while about, material, genre and additionalType resolve against terms. A publisher may describe one place as either, so a location reference can be a Place, a DefinedTerm, or a node typed both.
A place is one place
Where a publisher aligns a place to a term in GeoNames, GTAA or Wikidata – or names it by that term’s own URI – the place is keyed on that URI, and every dataset referencing it points at the same document. Two collections that both hold something about Maastricht give you one place, one id, one facet bucket:
{
places(where: { id: { in: ["https://sws.geonames.org/2751306/"] } }) {
items {
id
name {
value
}
latitude
longitude
authority
fetchedAt
}
}
}name, latitude and longitude come from the authority that owns the identity – authority names it, fetchedAt says when it was read – and not from the publishers, whose descriptions of a term they merely referenced are dropped rather than merged. Neither selected dataset ships coordinates at all, so this is the only place a map gets them.
A place nobody aligned keeps the publisher’s id and the publisher’s description. LOL does no entity matching: “Kessel” appears in both datasets as a local place and stays two documents, because neither publisher said they are the same. Such a place carries no authority.
The facet and the filter differ, deliberately. Only canonical places get a facet bucket, so an unaligned place cannot be reached by faceting locationCreated – but a filter on its id matches, and asking places for it works. Facets are discovery over a result set; filters are exact retrieval, and narrowing the first leaves the second whole.
An institution appears twice
publishers and organizations are separate collections, and the same institution is routinely in both – under two different URIs, with one name.
- A publisher is an agent in a dataset's registration, identified by whatever URI the registrant used.
- An organization is an agent the object data relates to a work.
Neither URI resolves the other, so you cannot join them: to go from an institution to its works, start from publishers, get its datasets, and filter works by those – the two-query pattern under From one institution.
From one institution
There is no join across collections, so this is deliberately two queries: find the publisher’s datasets, then filter works by them.
{
datasets(where: { publisher: { in: ["https://www.tracelimburg.nl/"] } }) {
items {
id
name {
value
}
}
}
}Feed the returned ids into the second:
{
creativeWorks(
perPage: 10
where: { dataset: { in: ["https://id.drapo.nl/dataset/drapo-schemaorg"] } }
) {
pagination {
total
}
}
}Filtering datasets by publisher is the right direction: each dataset carries its own publisher, so the answer is complete. Going the other way – asking a publisher which datasets it has – is not currently reliable, because a document records only one dataset.
What refers to an object
To answer “what refers to the object I am looking at, through any field?” – click “ink” as a term, get every work that has it as material, subject or genre – you need the list of fields that can hold an IRI at all. Don’t write that list down: ask the API, and it stays right when the schema moves.
The IRI scalar makes it decidable. A filter input takes one in list, and its element type says what the field holds:
input TermFilter {
in: [IRI!] # material, about, genre – identity
}
input KeywordFilter {
in: [String!] # name, identifier, license – literals
}A field references IRIs when its filter input’s in takes IRI. Nothing keys on a field name, so this works on any LDE schema, including one you changed.
Ask the schema
Two halves: which filter inputs take IRIs (asked once for the whole schema), and which filter input each collection’s where fields use.
{
filters: __schema {
types {
name
inputFields {
name
type {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
collections: __schema {
queryType {
fields {
name
args {
name
type {
name
inputFields {
name
type {
name
}
}
}
}
}
}
}
}Join the halves on the filter type name:
const namedType = (type) => (type.ofType ? namedType(type.ofType) : type);
// { TermFilter, PersonFilter, PlaceFilter, … } – the inputs that take IRIs
const iriFilters = new Set(
data.filters.types
.filter((type) =>
type.inputFields?.some(
(field) => field.name === 'in' && namedType(field.type).name === 'IRI',
),
)
.map((type) => type.name),
);
// { creativeWorks: ['id', 'dataset', 'creator', 'about', 'material', …], … }
const iriFields = Object.fromEntries(
data.collections.queryType.fields
.map((collection) => [
collection.name,
collection.args
.find((argument) => argument.name === 'where')
?.type.inputFields.filter((field) => iriFilters.has(field.type.name))
.map((field) => field.name),
])
.filter(([, fields]) => fields),
);or and and drop out on their own: they hold criteria, not filters, so they have no in. The collection list comes out of the same query, so a ninth collection needs no code change. Run this at boot and cache it – the surface only moves when the API is redeployed.
Why the shape: introspection has no string form for a type, so [IRI!] arrives as a wrapper chain (LIST → NON_NULL → SCALAR IRI) that you unwrap to the named type. And a query may nest at most two fields/inputFields lists – a third is rejected with Maximum introspection depth exceeded – which is why the IRI filter inputs are collected from __schema.types rather than walked per collection. type, ofType and args are free.
The query it builds
One or criterion per field, all carrying the same IRI. Drop id: that matches the object itself rather than what refers to it.
{
creativeWorks(
perPage: 10
where: {
or: [
{
about: {
in: ["https://id.drapo.nl/a005b7a1-562a-5d71-83c2-b26dd96dfae6"]
}
}
{
material: {
in: ["https://id.drapo.nl/a005b7a1-562a-5d71-83c2-b26dd96dfae6"]
}
}
{
creator: {
in: ["https://id.drapo.nl/a005b7a1-562a-5d71-83c2-b26dd96dfae6"]
}
}
]
}
) {
pagination {
total
}
items {
id
name {
value
}
about {
id
}
material {
id
}
}
}
}You need not know what kind of thing the IRI names. Every filter takes [IRI!], so a term IRI under creator is a valid criterion that matches nothing – a wrong guess costs recall of zero, not an error. Repeat per collection, aliased into one request, to cover objects of every type.
To show through which field the match ran, either ask for the reference fields on the items and compare them to the IRI you filtered on (as above), or give each field its own aliased sub-query with perPage: 0 – then the field is the response key and you get a count per relation.
Caveats
- Which collection a field points at is a naming convention.
material: TermFilterholds term IRIs, but nothing in the schema linksTermFilterto thetermscollection – you need that only to click through to the referenced object’s own record, since a reference already carries itslabel. Strip theFiltersuffix and look the rest up among the collections’ item types –Term→terms, and that half is introspected – which resolves all eight today. Assert it at boot, so a rename fails loudly instead of pointing a click-through at nothing. - Introspection must stay enabled. It is on the published image – the playground runs on it – and there is no other route to the field list.
inis the whole vocabulary for IRIs: set membership, no negation; the disjunction isor.