Skip to main content
Version: v1.0.3

Creating synthetic JSON entities from a field

Most examples create one entity per source record. This one also creates Country entities from the comma-separated country field in each mountain record, without a separate country file. Mountains link to those synthetic countries, can link to a parent mountain, and get a GeoJSON point from a sexagesimal coordinate string.

Get the data​

One file, a list of the 120 highest mountains, from free-json-datasets:

curl --fail --location --output data/mountains.json https://raw.githubusercontent.com/sharmadhiraj/free-json-datasets/master/docs/geography-environment/list_of_highest_mountains_on_earth.json

The file is an envelope with descriptive fields around a data array. Cassiopeia uses the array as its records and ignores the surrounding fields. One record looks like this:

{
"rank": "1",
"mountain_names": "Mount Everest, Sagarmatha, Chomolungma",
"height_rounded": "8,849 metres (29,032 ft)",
"prominence_rounded": "8,849 metres (29,032 ft)",
"range": "Mahalangur Himalaya",
"coordinates": "27°59′17″N 86°55′30″E",
"parent_mountain": "—",
"first_ascent": "1953",
"country_or_region": "Nepal, China"
}

Nothing here is directly usable as an entity ID, coordinate pair, or foreign key. The mapping has to shape each value first.

A coordinate function​

The coordinates field is a labelled degrees-minutes-seconds pair, 27°59′17″N 86°55′30″E, rather than decimal longitude and latitude. The dms_point mapping function parses both axes, applies the hemisphere signs, and returns an RFC 7946 GeoJSON Point in longitude-latitude order (clause 3.1.1). The point transformation then uses it for the GeoProperty:

location: {
source: "{{ dms_point(value=coordinates) }}",
type: "GeoProperty",
transformation: "point",
},

The function accepts typographic primes (′, ″) as well as ASCII apostrophes and quotes. Minutes and seconds may be omitted, and a trailing footnote marker is ignored, so 27°42′12″N 88°08′51″E * parses cleanly. dms_point is available to any mapping, alongside geohash.

A relationship from a type to itself​

parent_mountain names the massif a mountain rises from. The parent is another mountain in the same dataset, identified by name, so Mountain can relate to another Mountain:

parentMountain: {
source: "{% if parent_mountain is matching(pat='[A-Za-z]') %}{{ parent_mountain }}{% endif %}",
type: "Relationship",
target: {
entity: "Mountain",
},
},

Both sides use the mountain's primary name. The identity takes the first comma-separated item from mountain_names; parent_mountain already contains that name, so its guarded relationship produces the same cleaned URN. Lhotse therefore points to urn:ngsi-ld:Mountain:MountEverest, the ID minted for Everest. The highest summits use an em dash for no parent, and the guard omits their relationship instead of creating a placeholder.

Synthetic entities​

The two entity types meet through country_or_region, a comma-separated list such as Nepal, China. One mapping declaration uses it in two ways:

hasCountry: {
source: "{{ country_or_region }}",
type: "ListRelationship",
target: {
entity: "Country",
},
syntheticEntity: {
dataModel: "Country",
identity: {
entityName: "{{ this[0] }}",
},
attributes: {
name: {
source: "{{ this[0] }}",
type: "Property",
transformation: "string",
},
},
},
},

ListRelationship splits the field into tokens and creates one country link per token. This works here because every country name is one word. A string list also splits on whitespace, so United States would incorrectly become two countries. Each token is passed to the synthetic mapping as its record and read with this[0]. The synthetic entity uses the same URN as the relationship and gives the country a body, even though no country dataset exists.

Because the country URN comes from its name, every mountain that names Nepal points to urn:ngsi-ld:Country:Nepal. The many synthetic fragments for Nepal merge into one entity. The 120 mountains name eight countries, so eight Country entities are written.

The manifest​

{
version: "v1",
inputs: [
{
source: "data/mountains.json",
mapping: "mountain.json5",
format: "json",
},
],
output: {
target: "file",
directory: "out",
context: "none",
validation: {
mode: "fail-when-schema",
},
},
}

Mountain and Country are custom models with no published schemas. In fail-when-schema mode they are written without schema checks, just like the schemaless country in the previous graph.

Run it​

cassiopeia map \
--manifest manifest.json5

The same run in a container mounts this directory at /data and makes it the working directory, so the paths do not change. For Podman, replace docker with podman and drop the --user line: rootless Podman already maps the container's root to your user.

docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$PWD:/data" \
--workdir /data \
ghcr.io/vela-tools/cassiopeia:v1.0.3 \
map \
--manifest manifest.json5

From the repository root, the runner downloads the dataset, runs the mapping, and checks the output in one step:

cargo run -- run 13
cargo run -- run 13 --runtime docker

The run writes two files from the one input: Mountain.json with 120 entities and Country.json with 8.

Read the result​

Everest is the first row. Its coordinate is now a point, its height and prominence are whole-metre values extracted from 8,849 metres (29,032 ft), and it lists both countries it straddles. It has no parent, so the em dash produces no relationship:

{
"id": "urn:ngsi-ld:Mountain:MountEverest",
"type": "Mountain",
"name": {
"type": "Property",
"value": "Mount Everest"
},
"mountainRange": {
"type": "Property",
"value": "Mahalangur Himalaya"
},
"heightMetres": {
"type": "Property",
"value": 8849
},
"prominenceMetres": {
"type": "Property",
"value": 8849
},
"firstAscentYear": {
"type": "Property",
"value": 1953
},
"location": {
"type": "GeoProperty",
"value": {
"type": "Point",
"coordinates": [
86.925,
27.988055555555555
]
}
},
"hasCountry": {
"type": "ListRelationship",
"objectList": [
"urn:ngsi-ld:Country:Nepal",
"urn:ngsi-ld:Country:China"
],
"objectType": "Country"
}
}

Lhotse has a parent, and it points straight back at Everest:

{
"id": "urn:ngsi-ld:Mountain:Lhotse",
"type": "Mountain",
"parentMountain": {
"type": "Relationship",
"object": "urn:ngsi-ld:Mountain:MountEverest",
"objectType": "Mountain"
},
"hasCountry": {
"type": "ListRelationship",
"objectList": [
"urn:ngsi-ld:Country:China",
"urn:ngsi-ld:Country:Nepal"
],
"objectType": "Country"
}
}

And Nepal, named by no dataset, only by the mountains that stand in it, is a Country entity in its own right:

{
"id": "urn:ngsi-ld:Country:Nepal",
"type": "Country",
"name": {
"type": "Property",
"value": "Nepal"
}
}

An unclimbed peak carries none instead of a year. The guard keeps only four-digit years, so those mountains have no firstAscentYear. A single flat file therefore produces a connected graph of mountains, parents, and the countries named by the mountains.