Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions core/src/main/java/org/incenp/linkml/core/BinaryBlobConverter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* LinkML-Java - LinkML library for Java
* Copyright © 2026 Damien Goutte-Gattat
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* (1) Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* (2) Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* (3) Neither the name of the copyright holder nor the names its
* contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
* OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
* AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
* WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/

package org.incenp.linkml.core;

import java.util.Base64;

/**
* A converter for slots typed as <code>xsd:base64Binary</code> (represented as
* an array of <code>byte</code>).
* <p>
* Of note, such a type currently does not exist in LinkML, though it can easily
* be added in a custom type declaration. The default Javagen templates won’t
* render it as a <code>byte[]</code>, though.
*/
public class BinaryBlobConverter extends ScalarConverterBase {

// Not sure of a better way to get the Class<?> representing a byte[] array than
// having a dummy on which we can call getClass()... The correct way would be
// Byte.TYPE.arrayType(), but that method is only available from Java 12+.
private static byte[] array = new byte[1];

@Override
public Class<?> getType() {
return array.getClass();
}

@Override
protected Object convertImpl(Object raw, ConverterContext ctx) throws LinkMLRuntimeException {
try {
return Base64.getDecoder().decode(raw.toString());
} catch ( IllegalArgumentException e ) {
throw new LinkMLValueError("Invalid value, Base64-encoded binary blob expected", e);
}
}

public Object serialise(Object object, ConverterContext ctx) throws LinkMLRuntimeException {
if ( getType().isInstance(object) ) {
return Base64.getEncoder().encodeToString((byte[]) object);
} else {
throw new LinkMLInternalError("Invalid value, array of bytes expected");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ public ConverterContext() {
// whose range is set to the linkml:Any class).
converters.put(Object.class, new TransparentConverter());

addConverter(new BinaryBlobConverter());

objectConverterProvider = (t) -> new ObjectConverter(t);
typeResolver = new DefaultTypeDesignatorResolver();
}
Expand Down
25 changes: 25 additions & 0 deletions core/src/test/java/org/incenp/linkml/core/ObjectConverterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import org.incenp.linkml.core.samples.base.BaseURISelfDesignatedClass;
import org.incenp.linkml.core.samples.base.ClassWithCustomConverter;
import org.incenp.linkml.core.samples.base.ContainerOfAny;
import org.incenp.linkml.core.samples.base.ContainerOfBinaryData;
import org.incenp.linkml.core.samples.base.ContainerOfBooleanValues;
import org.incenp.linkml.core.samples.base.ContainerOfIRIIdentifiableObjects;
import org.incenp.linkml.core.samples.base.ContainerOfIdentifiedSelfDesignatedObjects;
Expand Down Expand Up @@ -773,6 +774,30 @@ void testParsingWithRefinedInheritedSlots() throws IOException {
Assertions.assertEquals(2, tdf.getBars().get(0).getLength());
}

@Test
void testParsingBinaryBlobs() throws IOException {
ContainerOfBinaryData cobd = parseString("checksum: aGVsbG8=\nchecksums:\n - d29ybGQ=",
ContainerOfBinaryData.class);
Assertions.assertEquals("hello", new String(cobd.getChecksum()));
Assertions.assertEquals("world", new String(cobd.getChecksums().get(0)));

// Can't use roundtrip for now because the generated Java code for binary blobs
// does not handle arrays the way we'd need it (two different arrays with the
// same contents are not equals). The Javagen template will need to be updated
// to
// use Arrays.hashCode() and Arrays.equals for those slots.
try {
Object raw = ctx.getConverter(cobd.getClass()).serialise(cobd, ctx);
Object cooked = ctx.getConverter(ContainerOfBinaryData.class).convert(raw, ctx);
Assertions.assertInstanceOf(ContainerOfBinaryData.class, cooked);
Assertions.assertEquals("hello", new String(((ContainerOfBinaryData) cooked).getChecksum()));
Assertions.assertEquals("world", new String(((ContainerOfBinaryData) cooked).getChecksums().get(0)));
} catch ( LinkMLRuntimeException e ) {
Assertions.fail("Unexpected exception", e);
}

}

private <T> T parse(String file, Class<T> target) throws IOException {
ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
FileInputStream stream = new FileInputStream(new File("src/test/resources/core/samples/", file));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package org.incenp.linkml.core.samples.base;

import java.net.URI;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.incenp.linkml.core.annotations.Converter;
import org.incenp.linkml.core.annotations.ExtensionHolder;
import org.incenp.linkml.core.annotations.Identifier;
import org.incenp.linkml.core.annotations.Inlined;
import org.incenp.linkml.core.annotations.LinkURI;
import org.incenp.linkml.core.annotations.Required;
import org.incenp.linkml.core.annotations.SlotName;
import org.incenp.linkml.core.annotations.TypeDesignator;
import org.incenp.linkml.core.CurieConverter;

@LinkURI("https://incenp.org/dvlpt/linkml-java/tests/samples#ContainerOfBinaryData")
public class ContainerOfBinaryData {

@LinkURI("https://incenp.org/dvlpt/linkml-java/tests/samples#checksum")
private byte[] checksum;

@LinkURI("https://incenp.org/dvlpt/linkml-java/tests/samples#checksums")
private List<byte[]> checksums;

public void setChecksum(byte[] checksum) {
this.checksum = checksum;
}

public byte[] getChecksum() {
return this.checksum;
}

public void setChecksums(List<byte[]> checksums) {
this.checksums = checksums;
}

public List<byte[]> getChecksums() {
return this.checksums;
}

public List<byte[]> getChecksums(boolean set) {
if ( this.checksums == null && set ) {
this.checksums = new ArrayList<>();
}
return this.checksums;
}

@Override
public String toString() {
StringBuilder sb = new StringBuilder();
Object o;
sb.append("ContainerOfBinaryData(");
if ( (o = this.getChecksum()) != null ) {
sb.append("checksum=");
sb.append(o);
sb.append(",");
}
if ( (o = this.getChecksums()) != null ) {
sb.append("checksums=");
sb.append(o);
sb.append(",");
}
sb.append(")");
return sb.toString();
}

@Override
public boolean equals(final Object o) {
if ( o == this ) return true;
if ( !(o instanceof ContainerOfBinaryData) ) return false;
final ContainerOfBinaryData other = (ContainerOfBinaryData) o;
if ( !other.canEqual((Object) this)) return false;
final Object this$checksum = this.getChecksum();
final Object other$checksum = other.getChecksum();
if ( this$checksum == null ? other$checksum != null : !this$checksum.equals(other$checksum) ) return false;
final Object this$checksums = this.getChecksums();
final Object other$checksums = other.getChecksums();
if ( this$checksums == null ? other$checksums != null : !this$checksums.equals(other$checksums) ) return false;
return true;
}

protected boolean canEqual(final Object other) {
return other instanceof ContainerOfBinaryData;
}

@Override
public int hashCode() {
final int PRIME = 59;
int result = 1;
final Object $checksum = this.getChecksum();
result = result * PRIME + ($checksum == null ? 43 : $checksum.hashCode());
final Object $checksums = this.getChecksums();
result = result * PRIME + ($checksums == null ? 43 : $checksums.hashCode());
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ license: https://spdx.org/licenses/BSD-3-Clause.html
prefixes:
linkml: https://w3id.org/linkml/
rt: https://incenp.org/dvlpt/linkml-java/tests/samples#
xsd: http://www.w3.org/2001/XMLSchema#

default_prefix: rt
default_range: string

imports:
- linkml:types

types:

blob:
uri: xsd:base64Binary
base: string
description: A Base64-encoded array of bytes.

classes:

SimpleClass:
Expand Down Expand Up @@ -482,6 +490,16 @@ classes:
inlined: true
inlined_as_list: false

ContainerOfBinaryData:
description: >-
A class with a slot expecting a binary blob.
attributes:
checksum:
range: blob
checksums:
range: blob
multivalued: true


enums:

Expand Down
19 changes: 15 additions & 4 deletions core/src/test/linkml/scripts/javagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ def cleanup_dir(directory: Path) -> None:
directory.rmdir()


# We need a custom generator for now to deal with the custom
# "binary blob" type.
class CustomGenerator(JavaGenerator):

def map_type(self, t, required = False):
if t.uri == "xsd:base64Binary":
return "byte[]"
else:
return super().map_type(t, required)


@click.option("--output-directory",
type=click.Path(dir_okay=True, file_okay=False, path_type=Path),
default=Path("core/src/test/java"))
Expand All @@ -31,10 +42,10 @@ def cli(output_directory: Path, schema_directory: Path) -> None:
cleaned_up_dirs[output_dir] = 1

package_name = package_dir.as_posix().replace("/", ".")
gen = JavaGenerator(schema,
true_enums=True,
use_aliases=True,
package=package_name)
gen = CustomGenerator(schema,
true_enums=True,
use_aliases=True,
package=package_name)
gen.serialize(output_dir, template_variant="org.incenp.linkml")


Expand Down
Loading