diff --git a/src/main/java/fr/inria/corese/core/next/api/AbstractBNode.java b/src/main/java/fr/inria/corese/core/next/api/AbstractBNode.java deleted file mode 100644 index 961ce814a..000000000 --- a/src/main/java/fr/inria/corese/core/next/api/AbstractBNode.java +++ /dev/null @@ -1,62 +0,0 @@ -package fr.inria.corese.core.next.api; - -import java.util.Objects; - -/** - * Abstract base class for blank nodes in an RDF graph. - *

- * Provides default implementations for {@link BNode#getID()}, - * {@link Object#equals(Object)}, and {@link Object#hashCode()}. - *

- */ -public abstract class AbstractBNode implements BNode { - - private static final String BLANK_NODE_PREFIX = "_:"; - - /** Internal identifier for the blank node (unique within a model) */ - private final String id; - - /** Serial version UID for serialization */ - private static final long serialVersionUID = 1L; - - /** - * Constructs a blank node with a specific identifier. - *

- * Warning: This bypasses the automatic ID generation and should only be - * used when restoring an existing RDF graph (e.g. during parsing or tests). - *

- */ - protected AbstractBNode(String id) { - this.id = Objects.requireNonNull(id, "Blank node ID must not be null"); - } - - @Override - public String getID() { - return id; - } - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (!(o instanceof BNode)) - return false; - BNode other = (BNode) o; - return id.equals(other.getID()); - } - - @Override - public int hashCode() { - return id.hashCode(); - } - - @Override - public String stringValue() { - return BLANK_NODE_PREFIX + id; - } - - @Override - public String toString() { - return BLANK_NODE_PREFIX + id; - } -} diff --git a/src/main/java/fr/inria/corese/core/next/api/FormatSerializer.java b/src/main/java/fr/inria/corese/core/next/api/FormatSerializer.java new file mode 100644 index 000000000..4741a9712 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/api/FormatSerializer.java @@ -0,0 +1,22 @@ +package fr.inria.corese.core.next.api; + +import java.io.Writer; + +import fr.inria.corese.core.next.impl.exception.SerializationException; + +public interface FormatSerializer { + + /** + * A serializer that converts a {@link Model} instance + * into a specific output format and writes it to a character stream. + * + * Implementations may follow standard RDF serialization formats + * (e.g., Turtle, N-Triples, JSON-LD), or define custom formats. + * + * @param writer the destination {@link Writer} for the serialized + * output + * @throws SerializationException if an error occurs during the serialization + * process + */ + void write(Writer writer) throws SerializationException; +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/api/Resource.java b/src/main/java/fr/inria/corese/core/next/api/Resource.java index 153907344..151a0f099 100644 --- a/src/main/java/fr/inria/corese/core/next/api/Resource.java +++ b/src/main/java/fr/inria/corese/core/next/api/Resource.java @@ -1,7 +1,8 @@ package fr.inria.corese.core.next.api; /** - * Super interface of all resources of an RDF graph (statements, IRI, blank nodes) as defined for RDF 1.2. + * Super interface of all resources of an RDF graph (statements, IRI, blank + * nodes) as defined for RDF 1.2. */ public interface Resource extends Value { diff --git a/src/main/java/fr/inria/corese/core/next/impl/common/serialization/FileFormat.java b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/FileFormat.java new file mode 100644 index 000000000..8242799b9 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/FileFormat.java @@ -0,0 +1,103 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import java.util.List; +import java.util.Objects; + +/** + * Represents a general file format, including its name, associated file + * extensions, and MIME types. + */ +public class FileFormat { + + private final String name; + private final List extensions; + private final List mimeTypes; + + /** + * Constructs a new FileFormat instance. + * + * @param name The human-readable name of the format. + * @param extensions The list of file extensions. + * @param mimeTypes The list of MIME types. + * @throws NullPointerException if name, extensions or mimeTypes is null or + * empty. + */ + public FileFormat(String name, List extensions, List mimeTypes) { + this.name = Objects.requireNonNull(name, "Format name cannot be null"); + this.extensions = List.copyOf(Objects.requireNonNull(extensions, "Extensions list cannot be null")); + this.mimeTypes = List.copyOf(Objects.requireNonNull(mimeTypes, "MIME types list cannot be null")); + + if (extensions.isEmpty()) { + throw new IllegalArgumentException("At least one file extension must be provided"); + } + if (mimeTypes.isEmpty()) { + throw new IllegalArgumentException("At least one MIME type must be provided"); + } + } + + /** + * Returns the name of the format. + * + * @return The format name. + */ + public String getName() { + return name; + } + + /** + * Returns the list of known file extensions. + * + * @return A list of extensions. + */ + public List getExtensions() { + return extensions; + } + + /** + * Returns the list of associated MIME types. + * + * @return A list of MIME types. + */ + public List getMimeTypes() { + return mimeTypes; + } + + /** + * Returns the default (primary) file extension. + * + * @return The first extension in the list. + */ + public String getDefaultExtension() { + return extensions.get(0); + } + + /** + * Returns the default (primary) MIME type. + * + * @return The first MIME type in the list. + */ + public String getDefaultMimeType() { + return mimeTypes.get(0); + } + + @Override + public String toString() { + return "FileFormat{name='%s', extensions=%s, mimeTypes=%s}".formatted(name, extensions, mimeTypes); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!(obj instanceof FileFormat other)) + return false; + return name.equalsIgnoreCase(other.name) + && extensions.equals(other.extensions) + && mimeTypes.equals(other.mimeTypes); + } + + @Override + public int hashCode() { + return Objects.hash(name.toLowerCase(), extensions, mimeTypes); + } +} diff --git a/src/main/java/fr/inria/corese/core/next/impl/common/serialization/FormatConfig.java b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/FormatConfig.java new file mode 100644 index 000000000..0e85acff7 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/FormatConfig.java @@ -0,0 +1,69 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import java.util.Objects; + +/** + * Configuration options for the {@link NTriplesFormat} serializer. + * Use {@link FormatConfig # Builder} to create instances. + */ +public class FormatConfig { + + private final String blankNodePrefix; + + + /** + * Private constructor to enforce usage of the Builder. + * + * @param builder The builder instance. + */ + private FormatConfig(Builder builder) { + this.blankNodePrefix = builder.blankNodePrefix; + + } + + /** + * Returns the prefix to use for blank nodes. + * + * @return The blank node prefix. + */ + public String getBlankNodePrefix() { + return blankNodePrefix; + } + + /** + * Builder class for {@link FormatConfig}. + */ + public static class Builder { + + private String blankNodePrefix = "_:"; + + /** + * Default constructor for the Builder. + * Initializes fields with default values. + */ + Builder() { + + } + + /** + * Sets the prefix to use for blank nodes. Default is "_:". + * + * @param blankNodePrefix The desired blank node prefix. + * @return The builder instance. + */ + public Builder blankNodePrefix(String blankNodePrefix) { + this.blankNodePrefix = Objects.requireNonNull(blankNodePrefix, "Blank node prefix cannot be null"); + return this; + } + + + /** + * Builds a new {@link FormatConfig} instance. + * + * @return A new NFormatConfig instance. + */ + public FormatConfig build() { + return new FormatConfig(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/impl/common/serialization/NQuadsFormat.java b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/NQuadsFormat.java new file mode 100644 index 000000000..daa921cf0 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/NQuadsFormat.java @@ -0,0 +1,304 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import fr.inria.corese.core.next.api.*; +import fr.inria.corese.core.next.impl.common.util.SerializationConstants; +import fr.inria.corese.core.next.impl.exception.SerializationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.util.Objects; + +/** + * Serializes a Corese {@link Model} into N-Quads format. + * This class provides a method to write the statements (quads) of a model to a given {@link Writer} + * according to the N-Quads specification, including support for named graphs (contexts). + */ +public class NQuadsFormat implements FormatSerializer { + + /** + * Logger for this class, used for logging potential issues or information during serialization. + */ + private static final Logger logger = LoggerFactory.getLogger(NQuadsFormat.class); + + private final Model model; + private final FormatConfig config; + + /** + * Constructs a new {@code NQuadsFormat} instance with the specified model and default configuration. + * + * @param model the {@link Model} to be serialized. Must not be null. + * @throws NullPointerException if the provided model is null. + */ + public NQuadsFormat(Model model) { + this(model, new FormatConfig.Builder().build()); + } + + /** + * Constructs a new {@code NQuadsFormat} instance with the specified model and custom configuration. + * + * @param model the {@link Model} to be serialized. Must not be null. + * @param config the {@link FormatConfig} to use for serialization. Must not be null. + * @throws NullPointerException if the provided model or config is null. + */ + public NQuadsFormat(Model model, FormatConfig config) { + this.model = Objects.requireNonNull(model, "Model cannot be null"); + this.config = Objects.requireNonNull(config, "Configuration cannot be null"); + } + + /** + * Writes the model to the given writer in N-Quads format. + * Each statement (quad) in the model is written on a new line, terminated by a dot and a newline character. + * + * @param writer the {@link Writer} to which the N-Quads output will be written. + * @throws SerializationException if an I/O error occurs during writing or if invalid data is encountered. + */ + @Override + public void write(Writer writer) throws SerializationException { + try { + for (Statement stmt : model) { + writeStatement(writer, stmt); + } + writer.flush(); + } catch (IOException e) { + throw new SerializationException("Failed to write", "NQuads", e); + } catch (IllegalArgumentException e) { + throw new SerializationException("Invalid data: " + e.getMessage(), "NQuads", e); + } + } + + /** + * Writes a single {@link Statement} (quad) to the writer in N-Quads format. + * The statement is written as "$subject $predicate $object $context ." if a context is present, + * or "$subject $predicate $object ." if no context is present (default graph). + * + * @param writer the {@link Writer} to which the statement will be written. + * @param stmt the {@link Statement} to write. + * @throws IOException if an I/O error occurs. + */ + private void writeStatement(Writer writer, Statement stmt) throws IOException { + writeValue(writer, stmt.getSubject()); + writer.write(SerializationConstants.SPACE); + writeValue(writer, stmt.getPredicate()); + writer.write(SerializationConstants.SPACE); + writeValue(writer, stmt.getObject()); + + Resource context = stmt.getContext(); + if (context != null) { + writer.write(SerializationConstants.SPACE); + writeValue(writer, context); + } + + writer.write(SerializationConstants.SPACE_POINT); + } + + /** + * Writes a single {@link Value} to the writer. + * Handles literals, blank nodes, and IRIs. + * + * @param writer the {@link Writer} to which the value will be written. + * @param value the {@link Value} to write. + * @throws IOException if an I/O error occurs. + * @throws IllegalArgumentException if the provided value is null or an unsupported type. + */ + private void writeValue(Writer writer, Value value) throws IOException { + validateValue(value); + + if (value.isLiteral()) { + writeLiteral(writer, (Literal) value); + } else if (value.isResource()) { + if (value.isIRI()) { + writeIRI(writer, (IRI) value); + } else if (value.isBNode()) { + writeBlankNode(writer, (Resource) value); + } else { + throw new IllegalArgumentException("Unsupported resource type for N-Quads serialization: " + value.getClass().getName()); + } + } else { + throw new IllegalArgumentException("Unsupported value type for N-Quads serialization: " + value.getClass().getName()); + } + } + + /** + * Writes a {@link Literal} to the writer in N-Quads format. + * Handles plain literals, language-tagged literals, and typed literals. + * + * @param writer the {@link Writer} to which the literal will be written. + * @param literal the {@link Literal} to write. + * @throws IOException if an I/O error occurs. + */ + private void writeLiteral(Writer writer, Literal literal) throws IOException { + writer.write(SerializationConstants.QUOTE); + writer.write(escapeLiteral(literal.stringValue())); + writer.write(SerializationConstants.QUOTE); + + // Gestion du langage + literal.getLanguage().ifPresent(lang -> { + try { + writer.write(SerializationConstants.AT_SIGN + lang); + } catch (IOException e) { + throw new UncheckedIOException("Error writing language tag", e); + } + }); + + if (!literal.getLanguage().isPresent()) { + IRI datatype = literal.getDatatype(); + if (datatype != null && !datatype.stringValue().equals(SerializationConstants.XSD_STRING)) { + writer.write(SerializationConstants.DATATYPE_SEPARATOR); + writeIRI(writer, datatype); + } + } + } + + /** + * Writes an {@link IRI} to the writer. + * The IRI's string representation must be enclosed in angle brackets for N-Quads. + * + * @param writer the {@link Writer} to which the IRI will be written. + * @param iri the {@link IRI} to write. + * @throws IOException if an I/O error occurs. + */ + private void writeIRI(Writer writer, IRI iri) throws IOException { + writer.write(SerializationConstants.LT); + writer.write(escapeIRI(iri.stringValue())); + writer.write(SerializationConstants.GT); + } + + /** + * Writes a blank node to the writer. + * Blank nodes are prefixed with "_:", and the identifier is appended. + * + * @param writer the {@link Writer} to which the blank node will be written. + * @param blankNode the {@link Resource} representing the blank node. + * @throws IOException if an I/O error occurs. + */ + private void writeBlankNode(Writer writer, Resource blankNode) throws IOException { + writer.write(config.getBlankNodePrefix()); + writer.write(blankNode.stringValue()); + } + + /** + * Validates and potentially escapes an IRI string. + * Throws an {@link IllegalArgumentException} if the IRI contains characters + * that are not allowed in N-Quads unescaped form (like spaces, quotes, angle brackets). + * + * @param iri The string value of the IRI to validate and escape. + * @return The validated and potentially escaped IRI string. + * @throws IllegalArgumentException if the IRI string is invalid. + */ + private String escapeIRI(String iri) { + + if (iri.contains(SerializationConstants.SPACE) || iri.contains(SerializationConstants.QUOTE) || + iri.contains(SerializationConstants.LT) || iri.contains(SerializationConstants.GT)) { + throw new IllegalArgumentException("Invalid IRI: contains illegal characters for N-Quads unescaped form: " + iri); + } + return iri; + } + + /** + * Escape special characters in N-Quads string literals. + * Handles backslash, double quote, and common control characters. + * Unicode escape sequences are used for unprintable characters. + * + * @param value The string value of the literal to escape. + * @return The escaped string suitable for N-Quads literal. + */ + private String escapeLiteral(String value) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '\n': + sb.append(SerializationConstants.BACK_SLASH).append('n'); + break; + case '\r': + sb.append(SerializationConstants.BACK_SLASH).append('r'); + break; + case '\t': + sb.append(SerializationConstants.BACK_SLASH).append('t'); + break; + case '\b': + sb.append(SerializationConstants.BACK_SLASH).append('b'); + break; + case '\f': + sb.append(SerializationConstants.BACK_SLASH).append('f'); + break; + case '"': + sb.append(SerializationConstants.BACK_SLASH).append(SerializationConstants.QUOTE); + break; + case '\\': + sb.append(SerializationConstants.BACK_SLASH).append(SerializationConstants.BACK_SLASH); + break; + default: + if (c <= 0x1F || c == 0x7F) { + sb.append(String.format("\\u%04X", (int) c)); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } + + /** + * Validates RDF values before serialization to ensure they conform to N-Quads rules. + * + * @param value The {@link Value} to validate. + * @throws IllegalArgumentException if the value is null or invalid. + */ + private void validateValue(Value value) { + if (value == null) { + logger.warn("Encountered a null value where a non-null value was expected for N-Quads serialization."); + throw new IllegalArgumentException("Value cannot be null in N-Quads format"); + } + + if (value.isLiteral()) { + validateLiteral((Literal) value); + } else if (value.isIRI()) { + validateIRI((IRI) value); + } + } + + /** + * Validates a {@link Literal} to ensure it conforms to RDF/N-Quads rules. + * Specifically checks for consistency between language tags and the rdf:langString datatype. + * + * @param literal The {@link Literal} to validate. + * @throws IllegalArgumentException if the literal is invalid (e.g., language tag with wrong datatype, + * or rdf:langString literal missing a language tag). + */ + private void validateLiteral(Literal literal) { + IRI datatype = literal.getDatatype(); + + + if (literal.getLanguage().isPresent()) { + + if (datatype == null || !datatype.stringValue().equals(SerializationConstants.RDF_LANGSTRING)) { + throw new IllegalArgumentException( + "Literal with language tag must use rdf:langString datatype. Found: " + (datatype != null ? datatype.stringValue() : "null")); + } + } else { + + if (datatype != null && datatype.stringValue().equals(SerializationConstants.RDF_LANGSTRING)) { + throw new IllegalArgumentException( + "rdf:langString literal must have a language tag."); + } + } + } + + /** + * Validates an {@link IRI} to ensure it conforms to N-Quads rules. + * Checks if the IRI string contains characters that are not allowed in N-Quads + * unescaped form, such as spaces. + * + * @param iri The {@link IRI} to validate. + * @throws IllegalArgumentException if the IRI contains spaces or is otherwise invalid. + */ + private void validateIRI(IRI iri) { + if (iri.stringValue().contains(SerializationConstants.SPACE)) { + throw new IllegalArgumentException("IRI contains spaces, which is not allowed in N-Quads unescaped form: " + iri.stringValue()); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/impl/common/serialization/NTriplesFormat.java b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/NTriplesFormat.java new file mode 100644 index 000000000..fc20c0e92 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/NTriplesFormat.java @@ -0,0 +1,305 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import fr.inria.corese.core.next.api.*; +import fr.inria.corese.core.next.impl.common.util.SerializationConstants; +import fr.inria.corese.core.next.impl.exception.SerializationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.util.Objects; + +/** + * Serializes a Corese {@link Model} into N-Triples format. + * This class provides a method to write the statements of a model to a given {@link Writer} + * according to the N-Triples specification. + */ +public class NTriplesFormat implements FormatSerializer { + + /** + * Logger for this class, used for logging potential issues or information during serialization. + */ + private static final Logger logger = LoggerFactory.getLogger(NTriplesFormat.class); + + private final Model model; + private final FormatConfig config; + + /** + * Constructs a new {@code NTriplesFormat} instance with the specified model and default configuration. + * + * @param model the {@link Model} to be serialized. Must not be null. + * @throws NullPointerException if the provided model is null. + */ + public NTriplesFormat(Model model) { + this(model, new FormatConfig.Builder().build()); + } + + /** + * Constructs a new {@code NTriplesFormat} instance with the specified model and custom configuration. + * + * @param model the {@link Model} to be serialized. Must not be null. + * @param config the {@link FormatConfig} to use for serialization. Must not be null. + * @throws NullPointerException if the provided model or config is null. + */ + public NTriplesFormat(Model model, FormatConfig config) { + this.model = Objects.requireNonNull(model, "Model cannot be null"); + this.config = Objects.requireNonNull(config, "Configuration cannot be null"); + } + + /** + * Writes the model to the given writer in N-Triples format. + * Each statement in the model is written on a new line, terminated by a dot and a newline character. + * + * @param writer the {@link Writer} to which the N-Triples output will be written. + * @throws SerializationException if an I/O error occurs during writing or if invalid data is encountered. + */ + @Override + public void write(Writer writer) throws SerializationException { + try { + for (Statement stmt : model) { + writeStatement(writer, stmt); + } + writer.flush(); + } catch (IOException e) { + throw new SerializationException("Failed to write", "NTriples", e); + } catch (IllegalArgumentException e) { + throw new SerializationException("Invalid data: " + e.getMessage(), "NTriples", e); + } + } + + /** + * Writes a single {@link Statement} to the writer in N-Triples format. + * The statement is written as "$subject $predicate $object ." + * N-Triples does not support contexts (named graphs). If a context is present, it's ignored and a warning is logged. + * + * @param writer the {@link Writer} to which the statement will be written. + * @param stmt the {@link Statement} to write. + * @throws IOException if an I/O error occurs. + */ + private void writeStatement(Writer writer, Statement stmt) throws IOException { + writeValue(writer, stmt.getSubject()); + writer.write(SerializationConstants.SPACE); + writeValue(writer, stmt.getPredicate()); + writer.write(SerializationConstants.SPACE); + writeValue(writer, stmt.getObject()); + + Resource context = stmt.getContext(); + if (context != null && logger.isWarnEnabled()) { + logger.warn("N-Triples format does not support named graphs. Context '{}' will be ignored for statement: {}", + context.stringValue(), stmt); + + } + + writer.write(SerializationConstants.SPACE_POINT); + } + + /** + * Writes a single {@link Value} to the writer. + * Handles literals, blank nodes, and IRIs. + * + * @param writer the {@link Writer} to which the value will be written. + * @param value the {@link Value} to write. + * @throws IOException if an I/O error occurs. + * @throws IllegalArgumentException if the provided value is null or an unsupported type. + */ + private void writeValue(Writer writer, Value value) throws IOException { + validateValue(value); + + if (value.isLiteral()) { + writeLiteral(writer, (Literal) value); + } else if (value.isResource()) { + if (value.isIRI()) { + writeIRI(writer, (IRI) value); + } else if (value.isBNode()) { + writeBlankNode(writer, (Resource) value); + } else { + throw new IllegalArgumentException("Unsupported resource type for N-Triples serialization: " + value.getClass().getName()); + } + } else { + throw new IllegalArgumentException("Unsupported value type for N-Triples serialization: " + value.getClass().getName()); + } + } + + /** + * Writes a {@link Literal} to the writer in N-Triples format. + * Handles plain literals, language-tagged literals, and typed literals. + * + * @param writer the {@link Writer} to which the literal will be written. + * @param literal the {@link Literal} to write. + * @throws IOException if an I/O error occurs. + */ + private void writeLiteral(Writer writer, Literal literal) throws IOException { + writer.write(SerializationConstants.QUOTE); + writer.write(escapeLiteral(literal.stringValue())); + writer.write(SerializationConstants.QUOTE); + + literal.getLanguage().ifPresent(lang -> { + try { + writer.write(SerializationConstants.AT_SIGN + lang); + } catch (IOException e) { + + throw new UncheckedIOException("Error writing language tag to stream", e); + } + }); + + if (!literal.getLanguage().isPresent()) { + IRI datatype = literal.getDatatype(); + if (datatype != null && !datatype.stringValue().equals(SerializationConstants.XSD_STRING)) { + writer.write(SerializationConstants.DATATYPE_SEPARATOR); + writeIRI(writer, datatype); + } + } + } + + /** + * Writes an {@link IRI} to the writer. + * The IRI's string representation must be enclosed in angle brackets for N-Triples. + * + * @param writer the {@link Writer} to which the IRI will be written. + * @param iri the {@link IRI} to write. + * @throws IOException if an I/O error occurs. + */ + private void writeIRI(Writer writer, IRI iri) throws IOException { + writer.write(SerializationConstants.LT); + writer.write(escapeIRI(iri.stringValue())); + writer.write(SerializationConstants.GT); + } + + /** + * Writes a blank node to the writer. + * Blank nodes are prefixed with "_:", and the identifier is appended. + * + * @param writer the {@link Writer} to which the blank node will be written. + * @param blankNode the {@link Resource} representing the blank node. + * @throws IOException if an I/O error occurs. + */ + private void writeBlankNode(Writer writer, Resource blankNode) throws IOException { + writer.write(config.getBlankNodePrefix()); + writer.write(blankNode.stringValue()); + } + + /** + * Validates and potentially escapes an IRI string. + * Throws an {@link IllegalArgumentException} if the IRI contains characters + * that are not allowed in N-Triples unescaped form (like spaces, quotes, angle brackets). + * + * @param iri The string value of the IRI to validate and escape. + * @return The validated and potentially escaped IRI string. + * @throws IllegalArgumentException if the IRI string is invalid. + */ + private String escapeIRI(String iri) { + + if (iri.contains(SerializationConstants.SPACE) || iri.contains(SerializationConstants.QUOTE) || iri.contains(SerializationConstants.LT) || iri.contains(SerializationConstants.GT)) { + throw new IllegalArgumentException("Invalid IRI: contains illegal characters for N-Triples unescaped form: " + iri); + } + return iri; + } + + /** + * Escape special characters in N-Triples string literals. + * Handles backslash, double quote, and common control characters. + * Unicode escape sequences are used for unprintable characters. + * + * @param value The string value of the literal to escape. + * @return The escaped string suitable for N-Triples literal. + */ + private String escapeLiteral(String value) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '\n': + sb.append(SerializationConstants.BACK_SLASH).append('n'); + break; + case '\r': + sb.append(SerializationConstants.BACK_SLASH).append('r'); + break; + case '\t': + sb.append(SerializationConstants.BACK_SLASH).append('t'); + break; + case '\b': // backspace + sb.append(SerializationConstants.BACK_SLASH).append('b'); + break; + case '\f': // form feed + sb.append(SerializationConstants.BACK_SLASH).append('f'); + break; + case '"': + sb.append(SerializationConstants.BACK_SLASH).append(SerializationConstants.QUOTE); + break; + case '\\': + sb.append(SerializationConstants.BACK_SLASH).append(SerializationConstants.BACK_SLASH); + break; + default: + if (c <= 0x1F || c == 0x7F) { + sb.append(String.format("\\u%04X", (int) c)); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } + + /** + * Validates RDF values before serialization to ensure they conform to N-Triples rules. + * + * @param value The {@link Value} to validate. + * @throws IllegalArgumentException if the value is null or invalid. + */ + private void validateValue(Value value) { + if (value == null) { + logger.warn("Encountered a null value where a non-null value was expected for N-Triples serialization."); + throw new IllegalArgumentException("Value cannot be null in N-Triples format"); + } + + if (value.isLiteral()) { + validateLiteral((Literal) value); + } else if (value.isIRI()) { + validateIRI((IRI) value); + } + + } + + /** + * Validates a {@link Literal} to ensure it conforms to RDF/N-Triples rules. + * Specifically checks for consistency between language tags and the rdf:langString datatype. + * + * @param literal The {@link Literal} to validate. + * @throws IllegalArgumentException if the literal is invalid (e.g., language tag with wrong datatype, + * or rdf:langString literal missing a language tag). + */ + private void validateLiteral(Literal literal) { + IRI datatype = literal.getDatatype(); + + + if (literal.getLanguage().isPresent()) { + + if (datatype == null || !datatype.stringValue().equals(SerializationConstants.RDF_LANGSTRING)) { + throw new IllegalArgumentException( + "Literal with language tag must use rdf:langString datatype. Found: " + (datatype != null ? datatype.stringValue() : "null")); + } + } else { + + if (datatype != null && datatype.stringValue().equals(SerializationConstants.RDF_LANGSTRING)) { + throw new IllegalArgumentException( + "rdf:langString literal must have a language tag."); + } + } + } + + /** + * Validates an {@link IRI} to ensure it conforms to N-Triples rules. + * Checks if the IRI string contains characters that are not allowed in N-Triples + * unescaped form, such as spaces. + * + * @param iri The {@link IRI} to validate. + * @throws IllegalArgumentException if the IRI contains spaces or is otherwise invalid. + */ + private void validateIRI(IRI iri) { + if (iri.stringValue().contains(SerializationConstants.SPACE)) { + throw new IllegalArgumentException("IRI contains spaces, which is not allowed in N-Triples unescaped form: " + iri.stringValue()); + } + } +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/impl/common/serialization/RdfFormat.java b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/RdfFormat.java new file mode 100644 index 000000000..fbea74d93 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/RdfFormat.java @@ -0,0 +1,176 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** + * Describes a semantic RDF serialization format, extending file-level metadata + * with RDF-specific capabilities. + * This class also acts as a central registry for all known RDF formats, + * providing static constants for common formats and utility methods for lookup. + */ +public class RdfFormat extends FileFormat { + + private final boolean supportsNamespaces; + private final boolean supportsNamedGraphs; + + public static final RdfFormat TURTLE = new RdfFormat( + "Turtle", + List.of("ttl"), + List.of("text/turtle"), + true, + false); + + + public static final RdfFormat NTRIPLES = new RdfFormat( + "N-Triples", + List.of("nt"), + List.of("application/n-triples", "text/plain"), + false, + false); + + public static final RdfFormat NQUADS = new RdfFormat( + "N-Quads", + List.of("nq"), + List.of("application/n-quads"), + false, + true); + + public static final RdfFormat JSONLD = new RdfFormat( + "JSON-LD", + List.of("jsonld"), + List.of("application/ld+json", "application/json"), + true, + true); + + public static final RdfFormat RDFXML = new RdfFormat( + "RDF/XML", + List.of("rdf", "xml"), + List.of("application/rdf+xml"), + true, + false); + + /** + * Constructs a new RDF format. + * + * @param name The name of the format (e.g., "Turtle"). + * @param extensions File extensions for this format (e.g., ["ttl"]). + * @param mimeTypes MIME types for this format (e.g., + * ["text/turtle"]). + * @param supportsNamespaces Whether the format supports prefixes/namespaces. + * @param supportsNamedGraphs Whether the format supports named graphs. + * serialization. + */ + public RdfFormat( + String name, + List extensions, + List mimeTypes, + boolean supportsNamespaces, + boolean supportsNamedGraphs) { + super(name, extensions, mimeTypes); + this.supportsNamespaces = supportsNamespaces; + this.supportsNamedGraphs = supportsNamedGraphs; + } + + /** + * Whether the format supports RDF prefixes (e.g., Turtle's @prefix declarations). + * + * @return true if the format supports explicit namespace declarations, false otherwise. + */ + public boolean supportsNamespaces() { + return supportsNamespaces; + } + + /** + * Whether the format supports named graphs (e.g., TriG, N-Quads). + */ + public boolean supportsNamedGraphs() { + return supportsNamedGraphs; + } + + + /** + * Finds a known RDF format by its name (case-insensitive). + * + * @param name The name of the format (e.g., "Turtle"). + * @return An Optional containing the matching RdfFormat if found. + */ + public static Optional byName(String name) { + String n = name.toLowerCase(Locale.ROOT); + return all().stream() + .filter(format -> format.getName().equalsIgnoreCase(n)) + .findFirst(); + } + + /** + * Finds a known RDF format by file extension (case-insensitive). + * + * @param extension The file extension (e.g., "ttl"). + * @return An Optional containing the matching RdfFormat if found. + */ + public static Optional byExtension(String extension) { + String ext = extension.toLowerCase(Locale.ROOT); + return all().stream() + .filter(format -> format.getExtensions().stream() + .anyMatch(e -> e.equalsIgnoreCase(ext))) + .findFirst(); + } + + /** + * Finds a known RDF format by MIME type (case-insensitive). + * + * @param mimeType The MIME type (e.g., "text/turtle"). + * @return An Optional containing the matching RdfFormat if found. + */ + public static Optional byMimeType(String mimeType) { + String mime = mimeType.toLowerCase(Locale.ROOT); + return all().stream() + .filter(format -> format.getMimeTypes().stream() + .anyMatch(m -> m.equalsIgnoreCase(mime))) + .findFirst(); + } + + /** + * Returns a list of all known RDF formats. + * + * @return An unmodifiable List of all RdfFormat constants. + */ + public static List all() { + return List.of(TURTLE, NTRIPLES, NQUADS, JSONLD, RDFXML); + } + + @Override + public String toString() { + return "%s [extensions: %s, mimeTypes: %s, prefixes: %s, namedGraphs: %s]".formatted( + getName(), + String.join(", ", getExtensions()), + String.join(", ", getMimeTypes()), + supportsNamespaces(), + supportsNamedGraphs()); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!(obj instanceof RdfFormat other)) + return false; + return getName().equalsIgnoreCase(other.getName()) + && getExtensions().equals(other.getExtensions()) + && getMimeTypes().equals(other.getMimeTypes()) + && supportsNamespaces == other.supportsNamespaces + && supportsNamedGraphs == other.supportsNamedGraphs; + } + + @Override + public int hashCode() { + return Objects.hash( + getName().toLowerCase(), + getExtensions(), + getMimeTypes(), + supportsNamespaces, + supportsNamedGraphs); + } +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/impl/common/serialization/Serializer.java b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/Serializer.java new file mode 100644 index 000000000..37427b832 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/impl/common/serialization/Serializer.java @@ -0,0 +1,55 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import fr.inria.corese.core.next.api.FormatSerializer; +import fr.inria.corese.core.next.api.Model; +import fr.inria.corese.core.next.impl.exception.SerializationException; + +import java.io.Writer; +import java.util.Objects; + +public class Serializer { + + private final Model model; + private final FormatConfig config; + + public Serializer(Model model) { + this(model, new FormatConfig.Builder().build()); + } + + public Serializer(Model model, FormatConfig config) { + this.model = Objects.requireNonNull(model, "Model cannot be null for serialization"); + this.config = Objects.requireNonNull(config, "FormatConfig cannot be null for serialization"); + } + + /** + * Serializes the RDF model to the given writer in the specified {@link RdfFormat}. + * + * @param writer the Writer to write the serialized data to. + * @param format the {@link RdfFormat} describing the desired serialization format. + * @throws SerializationException if an error occurs during serialization or if the format is not currently supported by an implementation. + */ + public void serialize(Writer writer, RdfFormat format) throws SerializationException { + Objects.requireNonNull(writer, "Writer cannot be null"); + Objects.requireNonNull(format, "RdfFormat cannot be null"); + + FormatSerializer formatSerializer; + + + if (format.equals(RdfFormat.NTRIPLES)) { + formatSerializer = new NTriplesFormat(model, config); + } else if (format.equals( RdfFormat.NQUADS)) { + formatSerializer = new NQuadsFormat(model, config); + } else if (format.equals( RdfFormat.TURTLE)) { + + throw new UnsupportedOperationException("Serialization to " + format.getName() + " format is not yet implemented."); + } else if (format.equals( RdfFormat.JSONLD)) { + throw new UnsupportedOperationException("Serialization to " + format.getName() + " format is not yet implemented."); + } else if (format.equals( RdfFormat.RDFXML)) { + throw new UnsupportedOperationException("Serialization to " + format.getName() + " format is not yet implemented."); + } else { + throw new IllegalArgumentException("Unknown or unsupported RdfFormat: " + format.getName()); + } + + formatSerializer.write(writer); + } +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/impl/common/util/SerializationConstants.java b/src/main/java/fr/inria/corese/core/next/impl/common/util/SerializationConstants.java new file mode 100644 index 000000000..54dfdca54 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/impl/common/util/SerializationConstants.java @@ -0,0 +1,83 @@ +package fr.inria.corese.core.next.impl.common.util; + +/** + * Provides a collection of constant strings used during the serialization process + * for various RDF formats. These constants aim to + * centralize common literal values to ensure consistency and maintainability + * across different serialization utilities. + */ +public class SerializationConstants { + + /** + * Represents a single space character (" "). + * Used for separating elements within an RDF triple or quad. + */ + public static final String SPACE = " "; + + /** + * Represents the termination sequence for an RDF statement format: + * a space, a dot, and a newline character (" .\n"). + */ + public static final String SPACE_POINT = " .\n"; + + /** + * Represents a single dot character ("."). + * Used as a terminator for RDF statements. + */ + public static final String POINT = "."; + + /** + * Represents the less-than sign ("<"). + * Used to enclose URIs in N-Triples and N-Quads. + */ + public static final String LT = "<"; + + /** + * Represents the greater-than sign (">"). + * Used to enclose URIs. + */ + public static final String GT = ">"; + + /** + * Represents the standard prefix for blank nodes ("_:"). + * Used to identify blank nodes. + */ + public static final String BNODE_PREFIX = "_:"; + + /** + * Represents a double-quote character ("\""). + * Used to enclose literal values. + */ + public static final String QUOTE = "\""; + /** + * Represents a backslash character ("\\"). + * Used for escaping special characters within string literals. + */ + public static final String BACK_SLASH = "\\"; + + /** + * The URI string for the XML Schema `xsd:string` datatype: + * {@code http://www.w3.org/2001/XMLSchema#string}. + */ + public static final String XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"; + + /** + * The URI string for the RDF `rdf:langString` datatype: + * {@code http://www.w3.org/1999/02/22-rdf-syntax-ns#langString}. + */ + public static final String RDF_LANGSTRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString"; + + // Nouvelle constante pour le préfixe de la balise de langue + public static final String AT_SIGN = "@"; + + // Nouvelle constante pour le séparateur de datatype + public static final String DATATYPE_SEPARATOR = "^^"; + + + /** + * Private constructor to prevent instantiation of this utility class. + * All members are static. + */ + private SerializationConstants() { + } +} \ No newline at end of file diff --git a/src/main/java/fr/inria/corese/core/next/impl/exception/SerializationException.java b/src/main/java/fr/inria/corese/core/next/impl/exception/SerializationException.java new file mode 100644 index 000000000..710ba0c93 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/impl/exception/SerializationException.java @@ -0,0 +1,50 @@ +package fr.inria.corese.core.next.impl.exception; + +/** + * Exception levée lors d'échecs de sérialisation/désérialisation RDF. + * Peut contenir des détails spécifiques au format (NTriples, JSON-LD, etc.). + */ +public class SerializationException extends Exception { + private final String formatName; + private final int lineNumber; + private final int columnNumber; + + public SerializationException(String message, String formatName, Throwable cause) { + this(message, formatName, -1, -1, cause); + } + + + public SerializationException(String message, String formatName, int lineNumber, int columnNumber, Throwable cause) { + super(buildMessage(message, formatName, lineNumber, columnNumber), cause); + this.formatName = formatName; + this.lineNumber = lineNumber; + this.columnNumber = columnNumber; + } + + private static String buildMessage(String base, String format, int line, int col) { + StringBuilder sb = new StringBuilder(base); + if (!"unknown".equals(format)) { + sb.append(" [Format: ").append(format).append("]"); + } + if (line > 0) { + sb.append(" at line ").append(line); + if (col > 0) { + sb.append(":").append(col); + } + } + return sb.toString(); + } + + + public String getFormatName() { + return formatName; + } + + public int getLineNumber() { + return lineNumber; + } + + public int getColumnNumber() { + return columnNumber; + } +} diff --git a/src/test/java/fr/inria/corese/core/next/impl/common/serialization/FileFormatTest.java b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/FileFormatTest.java new file mode 100644 index 000000000..2e3308e18 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/FileFormatTest.java @@ -0,0 +1,209 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Unit tests for {@link FileFormat}. + * + *

+ * Coverage : + *

+ *
    + *
  • Valid constructor & getters (parametrised)
  • + *
  • Null / empty argument validation
  • + *
  • Immutability of internal collections
  • + *
  • equals / hashCode full contract (symmetry, transitivity, null & type + * safety)
  • + *
  • Meaningful toString()
  • + *
+ */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class FileFormatTest { + + /* + * ---------------------------------------------------------- * + * Test data (re-usable across parameterised tests) * + * ---------------------------------------------------------- + */ + private static Stream validFormats() { + return Stream.of( + Arguments.of("Turtle", List.of("ttl"), List.of("text/turtle")), + Arguments.of("N-Triples", List.of("nt"), List.of("application/n-triples")), + Arguments.of("JSON-LD", List.of("jsonld"), List.of("application/ld+json")), + Arguments.of("RDF/XML", List.of("rdf", "xml"), List.of("application/rdf+xml")), + Arguments.of("TriG", List.of("trig"), List.of("application/trig"))); + } + + /* + * ---------------------------------------------------------- * + * Constructor & getters * + * ---------------------------------------------------------- + */ + @ParameterizedTest(name = "Create FileFormat: {0}") + @MethodSource("validFormats") + @DisplayName("Constructor populates fields correctly") + void constructor_sets_all_fields(String name, + List extensions, + List mimeTypes) { + + FileFormat format = new FileFormat(name, extensions, mimeTypes); + + assertAll("All getters reflect constructor arguments", + () -> assertEquals(name, format.getName(), "name"), + () -> assertEquals(extensions, format.getExtensions(), "extensions"), + () -> assertEquals(mimeTypes, format.getMimeTypes(), "mimeTypes"), + () -> assertEquals(extensions.get(0), format.getDefaultExtension(), "defaultExtension"), + () -> assertEquals(mimeTypes.get(0), format.getDefaultMimeType(), "defaultMimeType")); + } + + /* + * ---------------------------------------------------------- * + * Validation of constructor arguments * + * ---------------------------------------------------------- + */ + @Nested + class Constructor_argument_validation { + + private final List EXT = List.of("ttl"); + private final List MIME = List.of("text/turtle"); + + @Test + void null_name_throws_NPE() { + assertThrows(NullPointerException.class, + () -> new FileFormat(null, EXT, MIME)); + } + + @Test + void null_extensions_throws_NPE() { + assertThrows(NullPointerException.class, + () -> new FileFormat("Turtle", null, MIME)); + } + + @Test + void null_mimeTypes_throws_NPE() { + assertThrows(NullPointerException.class, + () -> new FileFormat("Turtle", EXT, null)); + } + + @Test + void empty_extensions_throws_IAE() { + assertThrows(IllegalArgumentException.class, + () -> new FileFormat("Turtle", List.of(), MIME)); + } + + @Test + void empty_mimeTypes_throws_IAE() { + assertThrows(IllegalArgumentException.class, + () -> new FileFormat("Turtle", EXT, List.of())); + } + } + + /* + * ---------------------------------------------------------- * + * Immutability & defensive copies * + * ---------------------------------------------------------- + */ + @Test + void internal_lists_are_immutable_and_defensively_copied() { + // Build mutable lists + List ext = new ArrayList<>(List.of("ttl")); + List mime = new ArrayList<>(List.of("text/turtle")); + FileFormat format = new FileFormat("Turtle", ext, mime); + + // Mutate originals AFTER construction + ext.add("bad"); + mime.add("bad/type"); + + // Verify defensive copy & immutability + assertEquals(List.of("ttl"), format.getExtensions(), "defensive copy for extensions"); + assertEquals(List.of("text/turtle"), format.getMimeTypes(), "defensive copy for mimeTypes"); + + // Returned lists must be unmodifiable + assertAll("Returned lists are unmodifiable", + () -> assertThrows(UnsupportedOperationException.class, + () -> format.getExtensions().add("new")), + () -> assertThrows(UnsupportedOperationException.class, + () -> format.getMimeTypes().add("new/type"))); + } + + /* + * ---------------------------------------------------------- * + * equals & hashCode contract * + * ---------------------------------------------------------- + */ + @Nested + class Equals_and_hashCode_contract { + + private final FileFormat base = new FileFormat("Turtle", List.of("ttl"), List.of("text/turtle")); + + @Test + void symmetry_and_case_insensitivity() { + FileFormat sameDifferentCase = new FileFormat("tUrTlE", List.of("ttl"), List.of("text/turtle")); + + assertEquals(base, sameDifferentCase); + assertEquals(sameDifferentCase, base); + assertEquals(base.hashCode(), sameDifferentCase.hashCode()); + } + + @Test + void transitivity() { + FileFormat a = new FileFormat("Turtle", List.of("ttl"), List.of("text/turtle")); + FileFormat b = new FileFormat("TURTLE", List.of("ttl"), List.of("text/turtle")); + FileFormat c = new FileFormat("turtle", List.of("ttl"), List.of("text/turtle")); + + assertAll( + () -> assertEquals(a, b), + () -> assertEquals(b, c), + () -> assertEquals(a, c)); + } + + @Test + void inequality_when_any_field_differs() { + FileFormat diffName = new FileFormat("N-Triples", List.of("ttl"), List.of("text/turtle")); + FileFormat diffExt = new FileFormat("Turtle", List.of("nt"), List.of("text/turtle")); + FileFormat diffMime = new FileFormat("Turtle", List.of("ttl"), List.of("application/n-triples")); + + assertAll( + () -> assertNotEquals(base, diffName), + () -> assertNotEquals(base, diffExt), + () -> assertNotEquals(base, diffMime), + () -> assertNotEquals(base, null), + () -> assertNotEquals(base, "some string")); + } + } + + /* + * ---------------------------------------------------------- * + * toString() * + * ---------------------------------------------------------- + */ + @Test + void toString_contains_all_relevant_information() { + FileFormat format = new FileFormat("Turtle", List.of("ttl"), List.of("text/turtle")); + + String out = format.toString(); + assertTrue( + Pattern.compile("Turtle", Pattern.CASE_INSENSITIVE).matcher(out).find(), + "name is present"); + assertTrue(out.contains("ttl"), "extension is present"); + assertTrue(out.contains("text/turtle"), "mime type is present"); + } +} diff --git a/src/test/java/fr/inria/corese/core/next/impl/common/serialization/FormatConfigTest.java b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/FormatConfigTest.java new file mode 100644 index 000000000..c1ee05f95 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/FormatConfigTest.java @@ -0,0 +1,56 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class FormatConfigTest { + + @Test + @DisplayName("Builder should create FormatConfig with default blank node prefix") + void builderShouldCreateWithDefaultBlankNodePrefix() { + + FormatConfig config = new FormatConfig.Builder().build(); + + + assertNotNull(config, "FormatConfig should not be null"); + assertEquals("_:", config.getBlankNodePrefix(), "Default blank node prefix should be '_:'"); + } + + @Test + @DisplayName("Builder should create FormatConfig with custom blank node prefix") + void builderShouldCreateWithCustomBlankNodePrefix() { + String customPrefix = "genid-"; + + + FormatConfig config = new FormatConfig.Builder() + .blankNodePrefix(customPrefix) + .build(); + + + assertNotNull(config, "FormatConfig should not be null"); + assertEquals(customPrefix, config.getBlankNodePrefix(), "Blank node prefix should match the custom value"); + } + + @Test + @DisplayName("blankNodePrefix method in Builder should throw NullPointerException for null prefix") + void blankNodePrefixShouldThrowForNull() { + + FormatConfig.Builder builder = new FormatConfig.Builder(); + + + assertThrows(NullPointerException.class, () -> builder.blankNodePrefix(null), + "Setting a null blank node prefix should throw NullPointerException"); + } + + @Test + @DisplayName("FormatConfig constructor should be private and only accessible via builder") + void constructorIsPrivateAndAccessibleViaBuilder() { + + FormatConfig config = new FormatConfig.Builder().build(); + assertNotNull(config); + } +} diff --git a/src/test/java/fr/inria/corese/core/next/impl/common/serialization/NQuadsFormatTest.java b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/NQuadsFormatTest.java new file mode 100644 index 000000000..5d1a3636d --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/NQuadsFormatTest.java @@ -0,0 +1,457 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import fr.inria.corese.core.next.api.*; +import fr.inria.corese.core.next.impl.common.vocabulary.RDF; +import fr.inria.corese.core.next.impl.exception.SerializationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.Iterator; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class NQuadsFormatTest { + + private Model model; + private FormatConfig config; + private NQuadsFormat nQuadsFormat; + + private Resource mockExPerson; + private IRI mockExName; + + private IRI mockExKnows; + private IRI mockExContext; + + + private final String lexJohn = "John Doe"; + + private final String hello = "Hello"; + + private Literal mockLiteralJohn; + private Literal mockLiteralHelloEn; + private Resource mockBNode1; + private Resource mockBNode2; + + @BeforeEach + void setUp() { + model = mock(Model.class); + config = new FormatConfig.Builder().build(); + nQuadsFormat = new NQuadsFormat(model, config); + + mockExPerson = createIRI("http://example.org/Person"); + mockExName = createIRI("http://example.org/name"); + + mockExKnows = createIRI("http://example.org/knows"); + + + mockLiteralJohn = createLiteral(lexJohn, null, null); + mockLiteralHelloEn = createLiteral(hello, null, "en"); + + mockBNode1 = createBlankNode("b1"); + mockBNode2 = createBlankNode("b2"); + mockExContext = createIRI("http://example.org/myGraph"); + } + + @Test + @DisplayName("Constructor should throw NullPointerException for null model") + void constructorShouldThrowForNullModel() { + assertThrows(NullPointerException.class, () -> new NQuadsFormat(null), "Model cannot be null"); + assertThrows(NullPointerException.class, () -> new NQuadsFormat(null, config), "Model cannot be null"); + } + + @Test + @DisplayName("Constructor should throw NullPointerException for null config") + void constructorShouldThrowForNullConfig() { + assertThrows(NullPointerException.class, () -> new NQuadsFormat(model, null), "Configuration cannot be null"); + } + + @Test + @DisplayName("Write should serialize simple statement correctly (default graph)") + void writeShouldSerializeSimpleStatement() throws SerializationException { + Statement stmt = createStatement( + mockExPerson, + mockExName, + mockLiteralJohn + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nQuadsFormat.write(writer); + + + String expected = String.format("<%s> <%s> \"%s\"", + mockExPerson.stringValue(), + mockExName.stringValue(), + escapeNQuadsString(lexJohn)) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should serialize statement with context (named graph)") + void writeShouldSerializeStatementWithContext() throws SerializationException { + Statement stmt = createStatement( + mockExPerson, + mockExName, + mockLiteralJohn, + mockExContext + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nQuadsFormat.write(writer); + + + String expected = String.format("<%s> <%s> \"%s\" <%s>", + mockExPerson.stringValue(), + mockExName.stringValue(), + escapeNQuadsString(lexJohn), + mockExContext.stringValue()) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should handle blank nodes with default prefix") + void writeShouldHandleBlankNodes() throws SerializationException { + Statement stmt = createStatement( + mockBNode1, + mockExKnows, + mockBNode2 + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nQuadsFormat.write(writer); + + String expected = String.format("_:%s <%s> _:%s", + mockBNode1.stringValue(), + mockExKnows.stringValue(), + mockBNode2.stringValue()) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should handle blank nodes in context with default prefix") + void writeShouldHandleBlankNodesInContext() throws SerializationException { + Resource blankNodeContext = createBlankNode("b3"); + Statement stmt = createStatement( + mockBNode1, + mockExKnows, + mockExPerson, + blankNodeContext + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nQuadsFormat.write(writer); + + String expected = String.format("_:%s <%s> <%s> _:%s", + mockBNode1.stringValue(), + mockExKnows.stringValue(), + mockExPerson.stringValue(), + blankNodeContext.stringValue()) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should handle blank nodes with custom prefix") + void writeShouldHandleBlankNodesWithCustomPrefix() throws SerializationException { + FormatConfig customConfig = new FormatConfig.Builder().blankNodePrefix("genid-").build(); + NQuadsFormat customSerializer = new NQuadsFormat(model, customConfig); + + Statement stmt = createStatement( + mockBNode1, + mockExKnows, + mockBNode2, + createBlankNode("b3") + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + customSerializer.write(writer); + + String expected = String.format("genid-%s <%s> genid-%s genid-%s", + mockBNode1.stringValue(), + mockExKnows.stringValue(), + mockBNode2.stringValue(), + createBlankNode("b3").stringValue()) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should throw SerializationException on IO error") + void writeShouldThrowOnIOException() throws IOException { + Statement stmt = createStatement( + mockExPerson, + mockExName, + mockLiteralJohn + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + Writer faultyWriter = mock(Writer.class); + doThrow(new IOException("Simulated IO error")).when(faultyWriter).write(anyString()); + + assertThrows(SerializationException.class, () -> nQuadsFormat.write(faultyWriter)); + } + + @Test + @DisplayName("Write should throw SerializationException on null subject value from Statement") + void writeShouldThrowOnNullSubjectValue() { + Statement stmt = mock(Statement.class); + when(stmt.getSubject()).thenReturn(null); + when(stmt.getPredicate()).thenReturn(mockExName); + when(stmt.getObject()).thenReturn(mockLiteralJohn); + when(stmt.getContext()).thenReturn(null); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + assertThrows(SerializationException.class, () -> nQuadsFormat.write(writer)); + } + + @Test + @DisplayName("Write should throw SerializationException on null predicate value from Statement") + void writeShouldThrowOnNullPredicateValue() { + Statement stmt = mock(Statement.class); + when(stmt.getSubject()).thenReturn(mockExPerson); + when(stmt.getPredicate()).thenReturn(null); + when(stmt.getObject()).thenReturn(mockLiteralJohn); + when(stmt.getContext()).thenReturn(null); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + assertThrows(SerializationException.class, () -> nQuadsFormat.write(writer)); + } + + @Test + @DisplayName("Write should throw SerializationException on null object value from Statement") + void writeShouldThrowOnNullObjectValue() { + Statement stmt = mock(Statement.class); + when(stmt.getSubject()).thenReturn(mockExPerson); + when(stmt.getPredicate()).thenReturn(mockExName); + when(stmt.getObject()).thenReturn(null); + when(stmt.getContext()).thenReturn(null); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + assertThrows(SerializationException.class, () -> nQuadsFormat.write(writer)); + } + + @Test + @DisplayName("Write should correctly handle null context (default graph)") + void writeShouldHandleNullContext() throws SerializationException { + Statement stmt = createStatement( + mockExPerson, + mockExName, + mockLiteralJohn, + null + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nQuadsFormat.write(writer); + + + String expected = String.format("<%s> <%s> \"%s\"", + mockExPerson.stringValue(), + mockExName.stringValue(), + escapeNQuadsString(lexJohn)) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "simple literal", + "literal with \"quotes\"", + "literal with \\ backslash", + "literal with \n newline", + "literal with \t tab", + "literal with \r carriage return", + "literal with \u0001 (SOH)", + "literal with \u007F (DEL)" + }) + @DisplayName("Write should handle various literal values with proper escaping") + void writeShouldHandleVariousLiterals(String literalValue) throws SerializationException { + + Literal literalMock = createLiteral(literalValue, null, null); + + Statement stmt = createStatement( + mockExPerson, + mockExName, + literalMock + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nQuadsFormat.write(writer); + + + String expectedEscapedLiteral = escapeNQuadsString(literalValue); + String expectedOutput = String.format("<%s> <%s> \"%s\"", + mockExPerson.stringValue(), + mockExName.stringValue(), + expectedEscapedLiteral) + " .\n"; + + assertEquals(expectedOutput, writer.toString()); + } + + + @Test + @DisplayName("Should handle literals with language tags") + void shouldHandleLiteralsWithLanguageTags() throws SerializationException { + Statement stmt = createStatement(mockExPerson, createIRI("http://example.org/greeting"), mockLiteralHelloEn); + + Model currentTestModel = mock(Model.class); + when(currentTestModel.iterator()).thenReturn(new MockStatementIterator(stmt)); + + Writer writer = new StringWriter(); + NQuadsFormat serializer = new NQuadsFormat(currentTestModel); + serializer.write(writer); + + String expectedOutput = String.format("<%s> <%s> \"%s\"@%s", + mockExPerson.stringValue(), + createIRI("http://example.org/greeting").stringValue(), + escapeNQuadsString(hello), + mockLiteralHelloEn.getLanguage().get()) + " .\n"; + + assertEquals(expectedOutput, writer.toString()); + } + + + /** + * Creates a mocked Literal object. + * Important: The `lexicalForm` is the *raw string value* of the literal, + * without N-Quads specific quotes, lang tags, or datatype URIs. + * The `NQuadsFormat` class is responsible for adding those. + * + * @param lexicalForm The raw string value of the literal (e.g., "hello", "123"). + * @param dataTypeIRI The IRI of the literal's datatype (e.g., XSD.INTEGER.getIRI()), or null for plain/lang-tagged. + * @param langTag The language tag (e.g., "en"), or null if not language-tagged. + * @return A mocked Literal instance. + */ + private Literal createLiteral(String lexicalForm, IRI dataTypeIRI, String langTag) { + Literal literal = mock(Literal.class); + when(literal.isLiteral()).thenReturn(true); + when(literal.isResource()).thenReturn(false); + when(literal.stringValue()).thenReturn(lexicalForm); + + if (langTag != null && !langTag.isEmpty()) { + when(literal.getLanguage()).thenReturn(Optional.of(langTag)); + + when(literal.getDatatype()).thenReturn(RDF.langString.getIRI()); + } else { + when(literal.getLanguage()).thenReturn(Optional.empty()); + when(literal.getDatatype()).thenReturn(dataTypeIRI); + } + return literal; + } + + /** + * Escapes a string according to N-Quads literal escaping rules. + * This helper is used in tests to construct the *expected* output strings. + * It mimics the behavior of NQuadsFormat's internal escapeLiteral method. + * + * @param s The string to escape. + * @return The escaped string. + */ + private String escapeNQuadsString(String s) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': // backspace + sb.append("\\b"); + break; + case '\f': // form feed + sb.append("\\f"); + break; + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + default: + if (c >= '\u0000' && c <= '\u001F' || c == '\u007F') { + sb.append(String.format("\\u%04X", (int) c)); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } + + + private static class MockStatementIterator implements Iterator { + private final Statement[] statements; + private int index = 0; + + MockStatementIterator(Statement... statements) { + this.statements = statements; + } + + @Override + public boolean hasNext() { + return index < statements.length; + } + + @Override + public Statement next() { + return statements[index++]; + } + } + + private Statement createStatement(Resource subject, IRI predicate, Value object) { + return createStatement(subject, predicate, object, null); + } + + private Statement createStatement(Resource subject, IRI predicate, Value object, Resource context) { + Statement stmt = mock(Statement.class); + when(stmt.getSubject()).thenReturn(subject); + when(stmt.getPredicate()).thenReturn(predicate); + when(stmt.getObject()).thenReturn(object); + when(stmt.getContext()).thenReturn(context); + return stmt; + } + + private Resource createBlankNode(String id) { + Resource blankNode = mock(Resource.class); + when(blankNode.isResource()).thenReturn(true); + when(blankNode.isBNode()).thenReturn(true); + when(blankNode.isIRI()).thenReturn(false); + when(blankNode.stringValue()).thenReturn(id); + return blankNode; + } + + private IRI createIRI(String uri) { + IRI iri = mock(IRI.class); + when(iri.isResource()).thenReturn(true); + when(iri.isIRI()).thenReturn(true); + when(iri.isBNode()).thenReturn(false); + when(iri.stringValue()).thenReturn(uri); + return iri; + } +} \ No newline at end of file diff --git a/src/test/java/fr/inria/corese/core/next/impl/common/serialization/NTriplesFormatTest.java b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/NTriplesFormatTest.java new file mode 100644 index 000000000..e00d89d41 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/NTriplesFormatTest.java @@ -0,0 +1,434 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import fr.inria.corese.core.next.api.*; +import fr.inria.corese.core.next.impl.common.vocabulary.RDF; + +import fr.inria.corese.core.next.impl.exception.SerializationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.Iterator; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class NTriplesFormatTest { + + private Model model; + private FormatConfig config; + private NTriplesFormat nTriplesFormat; + + private Resource mockExPerson; + private IRI mockExName; + private IRI mockExKnows; + + private final String lexJohn = "John Doe"; + private final String hello = "Hello"; + + private Literal mockLiteralJohn; + + private Literal mockLiteralHelloEn; + private Resource mockBNode1; + private Resource mockBNode2; + + @BeforeEach + void setUp() { + model = mock(Model.class); + config = new FormatConfig.Builder().build(); + nTriplesFormat = new NTriplesFormat(model, config); + + + mockExPerson = createIRI("http://example.org/Person"); + mockExName = createIRI("http://example.org/name"); + + mockExKnows = createIRI("http://example.org/knows"); + + + mockLiteralJohn = createLiteral(lexJohn, null, null); + + mockLiteralHelloEn = createLiteral(hello, null, "en"); + + mockBNode1 = createBlankNode("b1"); + mockBNode2 = createBlankNode("b2"); + } + + @Test + @DisplayName("Constructor should throw NullPointerException for null model") + void constructorShouldThrowForNullModel() { + assertThrows(NullPointerException.class, () -> new NTriplesFormat(null), "Model cannot be null"); + assertThrows(NullPointerException.class, () -> new NTriplesFormat(null, config), "Model cannot be null"); + } + + @Test + @DisplayName("Constructor should throw NullPointerException for null config") + void constructorShouldThrowForNullConfig() { + assertThrows(NullPointerException.class, () -> new NTriplesFormat(model, null), "Configuration cannot be null"); + } + + @Test + @DisplayName("Write should serialize simple statement correctly") + void writeShouldSerializeSimpleStatement() throws SerializationException { + Statement stmt = createStatement( + mockExPerson, + mockExName, + mockLiteralJohn + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nTriplesFormat.write(writer); + + + String expected = String.format("<%s> <%s> \"%s\"", + mockExPerson.stringValue(), + mockExName.stringValue(), + escapeNTriplesString(lexJohn)) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should serialize statement with context but ignore it (N-Triples)") + void writeShouldSerializeStatementWithContext() throws SerializationException { + IRI mockContext = createIRI("http://example.org/ctx"); + Statement stmt = createStatement( + mockExPerson, + mockExName, + mockLiteralJohn, + mockContext + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nTriplesFormat.write(writer); + + String expected = String.format("<%s> <%s> \"%s\"", + mockExPerson.stringValue(), + mockExName.stringValue(), + escapeNTriplesString(lexJohn)) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should handle blank nodes with default prefix") + void writeShouldHandleBlankNodes() throws SerializationException { + Statement stmt = createStatement( + mockBNode1, + mockExKnows, + mockBNode2 + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nTriplesFormat.write(writer); + + String expected = String.format("_:%s <%s> _:%s", + mockBNode1.stringValue(), + mockExKnows.stringValue(), + mockBNode2.stringValue()) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should handle blank nodes with custom prefix") + void writeShouldHandleBlankNodesWithCustomPrefix() throws SerializationException { + FormatConfig customConfig = new FormatConfig.Builder().blankNodePrefix("genid-").build(); + NTriplesFormat customSerializer = new NTriplesFormat(model, customConfig); + + Statement stmt = createStatement( + mockBNode1, + mockExKnows, + mockBNode2 + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + customSerializer.write(writer); + + String expected = String.format("genid-%s <%s> genid-%s", + mockBNode1.stringValue(), + mockExKnows.stringValue(), + mockBNode2.stringValue()) + " .\n"; + + assertEquals(expected, writer.toString()); + } + + @Test + @DisplayName("Write should throw SerializationException on IO error") + void writeShouldThrowOnIOException() throws IOException { + Statement stmt = createStatement( + mockExPerson, + mockExName, + mockLiteralJohn + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + Writer faultyWriter = mock(Writer.class); + + doThrow(new IOException("Simulated IO error")).when(faultyWriter).write(anyString()); + + assertThrows(SerializationException.class, () -> nTriplesFormat.write(faultyWriter)); + } + + @Test + @DisplayName("Write should throw SerializationException on null subject value from Statement") + void writeShouldThrowOnNullSubjectValue() { + Statement stmt = mock(Statement.class); + when(stmt.getSubject()).thenReturn(null); + when(stmt.getPredicate()).thenReturn(mockExName); + when(stmt.getObject()).thenReturn(mockLiteralJohn); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + assertThrows(SerializationException.class, () -> nTriplesFormat.write(writer)); + } + + @Test + @DisplayName("Write should throw SerializationException on null predicate value from Statement") + void writeShouldThrowOnNullPredicateValue() { + Statement stmt = mock(Statement.class); + when(stmt.getSubject()).thenReturn(mockExPerson); + when(stmt.getPredicate()).thenReturn(null); + when(stmt.getObject()).thenReturn(mockLiteralJohn); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + assertThrows(SerializationException.class, () -> nTriplesFormat.write(writer)); + } + + @Test + @DisplayName("Write should throw SerializationException on null object value from Statement") + void writeShouldThrowOnNullObjectValue() { + Statement stmt = mock(Statement.class); + when(stmt.getSubject()).thenReturn(mockExPerson); + when(stmt.getPredicate()).thenReturn(mockExName); + when(stmt.getObject()).thenReturn(null); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + assertThrows(SerializationException.class, () -> nTriplesFormat.write(writer)); + } + + @ParameterizedTest + @ValueSource(strings = { + "simple literal", + "literal with \"quotes\"", + "literal with \\ backslash", + "literal with \n newline", + "literal with \t tab", + "literal with \r carriage return", + "literal with \u0001 (SOH)", + "literal with \u007F (DEL)" + }) + @DisplayName("Write should handle various literal values with proper escaping") + void writeShouldHandleVariousLiterals(String literalValue) throws SerializationException { + Literal literalMock = createLiteral(literalValue, null, null); + + Statement stmt = createStatement( + mockExPerson, + mockExName, + literalMock + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt)); + + StringWriter writer = new StringWriter(); + nTriplesFormat.write(writer); + + String expectedEscapedLiteral = escapeNTriplesString(literalValue); + String expectedOutput = String.format("<%s> <%s> \"%s\"", + mockExPerson.stringValue(), + mockExName.stringValue(), + expectedEscapedLiteral) + " .\n"; + + assertEquals(expectedOutput, writer.toString()); + } + + @Test + @DisplayName("Write should handle multiple statements") + void writeShouldHandleMultipleStatements() throws SerializationException { + Statement stmt1 = createStatement( + mockExPerson, + mockExName, + createLiteral("o1", null, null) + ); + Statement stmt2 = createStatement( + mockBNode1, + mockExKnows, + mockExPerson, + createIRI("http://example.org/ctx") + ); + when(model.iterator()).thenReturn(new MockStatementIterator(stmt1, stmt2)); + + StringWriter writer = new StringWriter(); + nTriplesFormat.write(writer); + + String expectedOutput = String.format("<%s> <%s> \"%s\"", + mockExPerson.stringValue(), + mockExName.stringValue(), + escapeNTriplesString("o1")) + " .\n" + + String.format("_:%s <%s> <%s>", + mockBNode1.stringValue(), + mockExKnows.stringValue(), + mockExPerson.stringValue()) + " .\n"; + + assertEquals(expectedOutput, writer.toString()); + } + + @Test + @DisplayName("Should handle literals with language tags") + void shouldHandleLiteralsWithLanguageTags() throws SerializationException { + Statement stmt = createStatement(mockExPerson, createIRI("http://example.org/greeting"), mockLiteralHelloEn); + + Model currentTestModel = mock(Model.class); + when(currentTestModel.iterator()).thenReturn(new MockStatementIterator(stmt)); + + Writer writer = new StringWriter(); + NTriplesFormat serializer = new NTriplesFormat(currentTestModel); + serializer.write(writer); + + String expectedOutput = String.format("<%s> <%s> \"%s\"@%s", + mockExPerson.stringValue(), + createIRI("http://example.org/greeting").stringValue(), + escapeNTriplesString(hello), + mockLiteralHelloEn.getLanguage().get()) + " .\n"; + + assertEquals(expectedOutput, writer.toString()); + } + + + /** + * Escapes a string according to N-Triples literal escaping rules. + * This helper is used in tests to construct the *expected* output strings. + * It mimics the behavior of NTriplesFormat's internal escapeLiteral method. + * + * @param s The string to escape. + * @return The escaped string. + */ + private String escapeNTriplesString(String s) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + default: + if (c >= '\u0000' && c <= '\u001F' || c == '\u007F') { + sb.append(String.format("\\u%04X", (int) c)); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } + + + private static class MockStatementIterator implements Iterator { + private final Statement[] statements; + private int index = 0; + + MockStatementIterator(Statement... statements) { + this.statements = statements; + } + + @Override + public boolean hasNext() { + return index < statements.length; + } + + @Override + public Statement next() { + return statements[index++]; + } + } + + + /** + * Creates a mocked Literal object. + * Important: The `lexicalForm` is the *raw string value* of the literal, + * without N-Triples specific quotes, lang tags, or datatype URIs. + * The `NTriplesFormat` class is responsible for adding those. + * + * @param lexicalForm The raw string value of the literal (e.g., "hello", "123"). + * @param dataTypeIRI The IRI of the literal's datatype (e.g., XSD.INTEGER.getIRI()), or null for plain/lang-tagged. + * @param langTag The language tag (e.g., "en"), or null if not language-tagged. + * @return A mocked Literal instance. + */ + private Literal createLiteral(String lexicalForm, IRI dataTypeIRI, String langTag) { + Literal literal = mock(Literal.class); + when(literal.isLiteral()).thenReturn(true); + when(literal.isResource()).thenReturn(false); + when(literal.stringValue()).thenReturn(lexicalForm); + + if (langTag != null && !langTag.isEmpty()) { + when(literal.getLanguage()).thenReturn(Optional.of(langTag)); + + + when(literal.getDatatype()).thenReturn(RDF.langString.getIRI()); + } else { + when(literal.getLanguage()).thenReturn(Optional.empty()); + when(literal.getDatatype()).thenReturn(dataTypeIRI); + } + return literal; + } + + private Statement createStatement(Resource subject, IRI predicate, Value object) { + return createStatement(subject, predicate, object, null); + } + + private Statement createStatement(Resource subject, IRI predicate, Value object, Resource context) { + Statement stmt = mock(Statement.class); + when(stmt.getSubject()).thenReturn(subject); + when(stmt.getPredicate()).thenReturn(predicate); + when(stmt.getObject()).thenReturn(object); + when(stmt.getContext()).thenReturn(context); + return stmt; + } + + private Resource createBlankNode(String id) { + Resource blankNode = mock(Resource.class); + when(blankNode.isResource()).thenReturn(true); + when(blankNode.isBNode()).thenReturn(true); + when(blankNode.isIRI()).thenReturn(false); + when(blankNode.stringValue()).thenReturn(id); + return blankNode; + } + + private IRI createIRI(String uri) { + IRI iri = mock(IRI.class); + when(iri.isResource()).thenReturn(true); + when(iri.isIRI()).thenReturn(true); + when(iri.isBNode()).thenReturn(false); + when(iri.stringValue()).thenReturn(uri); + return iri; + } +} \ No newline at end of file diff --git a/src/test/java/fr/inria/corese/core/next/impl/common/serialization/RdfFormatTest.java b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/RdfFormatTest.java new file mode 100644 index 000000000..8f0df66d0 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/RdfFormatTest.java @@ -0,0 +1,300 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class RdfFormatTest { + + + @Test + @DisplayName("Constructor should correctly initialize fields and getters should return correct values") + void constructorAndGetters() { + String name = "TestFormat"; + List extensions = List.of("tf", "testf"); + List mimeTypes = List.of("application/x-test", "text/test"); + boolean supportsNamespaces = true; + boolean supportsNamedGraphs = false; + + RdfFormat format = new RdfFormat(name, extensions, mimeTypes, supportsNamespaces, supportsNamedGraphs); + + assertEquals(name, format.getName(), "Name should match constructor argument"); + assertEquals(extensions, format.getExtensions(), "Extensions list should match constructor argument"); + assertEquals(mimeTypes, format.getMimeTypes(), "MIME types list should match constructor argument"); + assertEquals(supportsNamespaces, format.supportsNamespaces(), "supportsNamespaces should match constructor argument"); + assertEquals(supportsNamedGraphs, format.supportsNamedGraphs(), "supportsNamedGraphs should match constructor argument"); + + assertEquals("tf", format.getDefaultExtension(), "Default extension should be the first in list"); + assertEquals("application/x-test", format.getDefaultMimeType(), "Default MIME type should be the first in list"); + } + + @Test + @DisplayName("Constructor should throw NullPointerException for null name") + void constructorThrowsForNullName() { + List extensions = List.of("tf"); + List mimeTypes = List.of("text/plain"); + assertThrows(NullPointerException.class, + () -> new RdfFormat(null, extensions, mimeTypes, true, false), + "Constructor should throw NPE for null name"); + } + + @Test + @DisplayName("Constructor should throw NullPointerException for null extensions list") + void constructorThrowsForNullExtensions() { + List mimeTypes = List.of("text/plain"); + assertThrows(NullPointerException.class, + () -> new RdfFormat("Test", null, mimeTypes, true, false), + "Constructor should throw NPE for null extensions list"); + } + + + @Test + @DisplayName("Constructor should throw NullPointerException for null mimeTypes list") + void constructorThrowsForNullMimeTypes() { + List extensions = List.of("tf"); + assertThrows(NullPointerException.class, + () -> new RdfFormat("Test", extensions, null, true, false), + "Constructor should throw NPE for null mimeTypes list"); + } + + + @Test + @DisplayName("toString() should return a meaningful string representation") + void testToString() { + RdfFormat format = RdfFormat.TURTLE; + String expected = "Turtle [extensions: ttl, mimeTypes: text/turtle, prefixes: true, namedGraphs: false]"; + + assertEquals(expected, format.toString(), "toString() should match expected format"); + } + + @Test + @DisplayName("equals() should return true for identical objects") + void equalsIdenticalObjects() { + RdfFormat format1 = new RdfFormat("Test", List.of("t"), List.of("text/t"), true, false); + + assertEquals(format1, format1, "Object should be equal to itself"); + } + + @Test + @DisplayName("equals() should return true for logically equal objects") + void equalsLogicallyEqualObjects() { + RdfFormat format1 = new RdfFormat("Test", List.of("t"), List.of("text/t"), true, false); + RdfFormat format2 = new RdfFormat("test", List.of("t"), List.of("text/t"), true, false); // Name case-insensitive + + assertEquals(format1, format2, "Objects with same properties (case-insensitive name) should be equal"); + } + + @Test + @DisplayName("equals() should return false for objects with different names") + void equalsDifferentNames() { + RdfFormat format1 = new RdfFormat("Test1", List.of("t"), List.of("text/t"), true, false); + RdfFormat format2 = new RdfFormat("Test2", List.of("t"), List.of("text/t"), true, false); + + assertNotEquals(format1, format2, "Objects with different names should not be equal"); + } + + @Test + @DisplayName("equals() should return false for objects with different extensions") + void equalsDifferentExtensions() { + RdfFormat format1 = new RdfFormat("Test", List.of("t1"), List.of("text/t"), true, false); + RdfFormat format2 = new RdfFormat("Test", List.of("t2"), List.of("text/t"), true, false); + + assertNotEquals(format1, format2, "Objects with different extensions should not be equal"); + } + + @Test + @DisplayName("equals() should return false for objects with different mimeTypes") + void equalsDifferentMimeTypes() { + RdfFormat format1 = new RdfFormat("Test", List.of("t"), List.of("text/t1"), true, false); + RdfFormat format2 = new RdfFormat("Test", List.of("t"), List.of("text/t2"), true, false); + + assertNotEquals(format1, format2, "Objects with different mimeTypes should not be equal"); + } + + @Test + @DisplayName("equals() should return false for objects with different supportsNamespaces") + void equalsDifferentSupportsNamespaces() { + RdfFormat format1 = new RdfFormat("Test", List.of("t"), List.of("text/t"), true, false); + RdfFormat format2 = new RdfFormat("Test", List.of("t"), List.of("text/t"), false, false); + + assertNotEquals(format1, format2, "Objects with different supportsNamespaces should not be equal"); + } + + @Test + @DisplayName("equals() should return false for objects with different supportsNamedGraphs") + void equalsDifferentSupportsNamedGraphs() { + RdfFormat format1 = new RdfFormat("Test", List.of("t"), List.of("text/t"), true, true); + RdfFormat format2 = new RdfFormat("Test", List.of("t"), List.of("text/t"), true, false); + + assertNotEquals(format1, format2, "Objects with different supportsNamedGraphs should not be equal"); + } + + @Test + @DisplayName("hashCode() should be consistent with equals()") + void hashCodeConsistency() { + RdfFormat format1 = new RdfFormat("Test", List.of("t"), List.of("text/t"), true, false); + RdfFormat format2 = new RdfFormat("test", List.of("t"), List.of("text/t"), true, false); + + assertEquals(format1, format2, "Objects should be equal"); + assertEquals(format1.hashCode(), format2.hashCode(), "Hash codes must be equal for equal objects"); + } + + @Test + @DisplayName("hashCode() should return different values for unequal objects (high probability)") + void hashCodeDifference() { + RdfFormat format1 = new RdfFormat("Test1", List.of("t"), List.of("text/t"), true, false); + RdfFormat format2 = new RdfFormat("Test2", List.of("t"), List.of("text/t"), true, false); + + assertNotEquals(format1.hashCode(), format2.hashCode(), "Hash codes should ideally be different for unequal objects"); + } + + + @Test + @DisplayName("TURTLE constant should be correctly defined") + void turtleConstant() { + RdfFormat turtle = RdfFormat.TURTLE; + + assertNotNull(turtle, "TURTLE constant should not be null"); + assertEquals("Turtle", turtle.getName()); + assertTrue(turtle.getExtensions().contains("ttl")); + assertTrue(turtle.getMimeTypes().contains("text/turtle")); + assertTrue(turtle.supportsNamespaces()); + assertFalse(turtle.supportsNamedGraphs()); + } + + @Test + @DisplayName("NTRIPLES constant should be correctly defined") + void nTriplesConstant() { + RdfFormat ntriples = RdfFormat.NTRIPLES; + + assertNotNull(ntriples, "NTRIPLES constant should not be null"); + assertEquals("N-Triples", ntriples.getName()); + assertTrue(ntriples.getExtensions().contains("nt")); + assertTrue(ntriples.getMimeTypes().contains("application/n-triples")); + assertFalse(ntriples.supportsNamespaces()); + assertFalse(ntriples.supportsNamedGraphs()); + } + + @Test + @DisplayName("NQUADS constant should be correctly defined") + void nQuadsConstant() { + RdfFormat nquads = RdfFormat.NQUADS; + + assertNotNull(nquads, "NQUADS constant should not be null"); + assertEquals("N-Quads", nquads.getName()); + assertTrue(nquads.getExtensions().contains("nq")); + assertTrue(nquads.getMimeTypes().contains("application/n-quads")); + assertFalse(nquads.supportsNamespaces()); + assertTrue(nquads.supportsNamedGraphs()); + } + + @Test + @DisplayName("JSONLD constant should be correctly defined") + void jsonLdConstant() { + RdfFormat jsonld = RdfFormat.JSONLD; + + assertNotNull(jsonld, "JSONLD constant should not be null"); + assertEquals("JSON-LD", jsonld.getName()); + assertTrue(jsonld.getExtensions().contains("jsonld")); + assertTrue(jsonld.getMimeTypes().contains("application/ld+json")); + assertTrue(jsonld.supportsNamespaces()); + assertTrue(jsonld.supportsNamedGraphs()); + } + + @Test + @DisplayName("RDFXML constant should be correctly defined") + void rdfXmlConstant() { + RdfFormat rdfxml = RdfFormat.RDFXML; + + assertNotNull(rdfxml, "RDFXML constant should not be null"); + assertEquals("RDF/XML", rdfxml.getName()); + assertTrue(rdfxml.getExtensions().contains("rdf")); + assertTrue(rdfxml.getExtensions().contains("xml")); + assertTrue(rdfxml.getMimeTypes().contains("application/rdf+xml")); + assertTrue(rdfxml.supportsNamespaces()); + assertFalse(rdfxml.supportsNamedGraphs()); + } + + + @Test + @DisplayName("byName() should find existing format by name (case-insensitive)") + void byNameFound() { + Optional format = RdfFormat.byName("TuRtLe"); + + assertTrue(format.isPresent(), "Turtle format should be found by name"); + assertEquals(RdfFormat.TURTLE, format.get(), "Found format should be the TURTLE constant"); + } + + @Test + @DisplayName("byName() should return empty Optional for non-existent name") + void byNameNotFound() { + Optional format = RdfFormat.byName("NonExistentFormat"); + + assertFalse(format.isPresent(), "Non-existent format should not be found"); + } + + @Test + @DisplayName("byExtension() should find existing format by extension (case-insensitive)") + void byExtensionFound() { + Optional format = RdfFormat.byExtension("TTL"); + + assertTrue(format.isPresent(), "Turtle format should be found by extension"); + assertEquals(RdfFormat.TURTLE, format.get(), "Found format should be the TURTLE constant"); + + Optional rdfXmlFormat = RdfFormat.byExtension("XML"); + assertTrue(rdfXmlFormat.isPresent()); + assertEquals(RdfFormat.RDFXML, rdfXmlFormat.get()); + } + + @Test + @DisplayName("byExtension() should return empty Optional for non-existent extension") + void byExtensionNotFound() { + Optional format = RdfFormat.byExtension("xyz"); + + assertFalse(format.isPresent(), "Non-existent extension should not find a format"); + } + + @Test + @DisplayName("byMimeType() should find existing format by MIME type (case-insensitive)") + void byMimeTypeFound() { + Optional format = RdfFormat.byMimeType("text/TuRtLe"); + + assertTrue(format.isPresent(), "Turtle format should be found by MIME type"); + assertEquals(RdfFormat.TURTLE, format.get(), "Found format should be the TURTLE constant"); + + Optional nTriplesFormat = RdfFormat.byMimeType("text/plain"); + + assertTrue(nTriplesFormat.isPresent()); + assertEquals(RdfFormat.NTRIPLES, nTriplesFormat.get()); + } + + @Test + @DisplayName("byMimeType() should return empty Optional for non-existent MIME type") + void byMimeTypeNotFound() { + Optional format = RdfFormat.byMimeType("application/x-unknown"); + + assertFalse(format.isPresent(), "Non-existent MIME type should not find a format"); + } + + @Test + @DisplayName("all() should return a list containing all predefined formats") + void allFormats() { + List allFormats = RdfFormat.all(); + + assertNotNull(allFormats, "List of all formats should not be null"); + assertEquals(5, allFormats.size(), "List should contain 5 predefined formats"); // TURTLE, NTRIPLES, NQUADS, JSONLD, RDFXML + + assertTrue(allFormats.contains(RdfFormat.TURTLE)); + assertTrue(allFormats.contains(RdfFormat.NTRIPLES)); + assertTrue(allFormats.contains(RdfFormat.NQUADS)); + assertTrue(allFormats.contains(RdfFormat.JSONLD)); + assertTrue(allFormats.contains(RdfFormat.RDFXML)); + + assertThrows(UnsupportedOperationException.class, () -> allFormats.add(RdfFormat.TURTLE), + "The list returned by all() should be unmodifiable"); + } +} \ No newline at end of file diff --git a/src/test/java/fr/inria/corese/core/next/impl/common/serialization/SerializerTest.java b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/SerializerTest.java new file mode 100644 index 000000000..0e8700810 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/impl/common/serialization/SerializerTest.java @@ -0,0 +1,98 @@ +package fr.inria.corese.core.next.impl.common.serialization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.verify; + +import java.io.Writer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockitoAnnotations; + +import fr.inria.corese.core.next.api.Model; +import fr.inria.corese.core.next.impl.exception.SerializationException; + +class SerializerTest { + + private Serializer serializer; + + @Mock + private Model mockModel; + @Mock + private FormatConfig mockConfig; + @Mock + private Writer mockWriter; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + serializer = new Serializer(mockModel, mockConfig); + } + + // --- Constructor tests --- + + @Test + @DisplayName("Constructor should throw NullPointerException for null model") + void constructorShouldThrowForNullModel() { + assertThrows(NullPointerException.class, () -> new Serializer(null), "Model cannot be null"); + assertThrows(NullPointerException.class, () -> new Serializer(null, mockConfig), "Model cannot be null"); + } + + @Test + @DisplayName("Constructor should throw NullPointerException for null config") + void constructorShouldThrowForNullConfig() { + assertThrows(NullPointerException.class, () -> new Serializer(mockModel, null), "FormatConfig cannot be null"); + } + + // --- Tests for the arguments of the serialize method --- + + @Test + @DisplayName("serialize should throw NullPointerException for null writer") + void serializeShouldThrowForNullWriter() { + assertThrows(NullPointerException.class, () -> serializer.serialize(null, RdfFormat.NTRIPLES), + "Writer cannot be null"); + } + + @Test + @DisplayName("serialize should throw NullPointerException for null format") + void serializeShouldThrowForNullFormat() { + assertThrows(NullPointerException.class, () -> serializer.serialize(mockWriter, null), + "RdfFormat cannot be null"); + } + + // --- Serialization delegation tests --- + + @Test + @DisplayName("serialize should delegate to NTriplesFormat for NTRIPLES format") + void serializeShouldDelegateToNTriplesFormat() throws SerializationException { + try (MockedConstruction mockedNtConstructor = mockConstruction(NTriplesFormat.class)) { + serializer.serialize(mockWriter, RdfFormat.NTRIPLES); + + assertEquals(1, mockedNtConstructor.constructed().size(), + "NTriplesFormat constructor should be called once"); + + NTriplesFormat createdNtSerializer = mockedNtConstructor.constructed().get(0); + + verify(createdNtSerializer).write(mockWriter); + } + } + + @Test + @DisplayName("serialize should delegate to NQuadsFormat for NQUADS format") + void serializeShouldDelegateToNQuadsFormat() throws SerializationException { + try (MockedConstruction mockedNqConstructor = mockConstruction(NQuadsFormat.class)) { + serializer.serialize(mockWriter, RdfFormat.NQUADS); + + assertEquals(1, mockedNqConstructor.constructed().size(), "NQuadsFormat constructor should be called once"); + NQuadsFormat createdNqSerializer = mockedNqConstructor.constructed().get(0); + + verify(createdNqSerializer).write(mockWriter); + } + } + +} \ No newline at end of file