/** * Copyright 2016 Hortonworks. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. **/ package com.hortonworks.registries.common; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.DatabindContext; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver; import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; import com.fasterxml.jackson.databind.type.TypeFactory; import com.google.common.collect.ImmutableList; import com.google.common.collect.LinkedHashMultiset; import com.google.common.collect.Multiset; import com.hortonworks.registries.common.exception.ParserException; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; //TODO Make this class Jackson Compatible. public class Schema implements Serializable { public enum Type { // Don't change the order of this enum to prevent bugs. If you need to add a new entry do so by adding it to the end. BOOLEAN(Boolean.class), BYTE(Byte.class), // 8-bit signed integer SHORT(Short.class), // 16-bit INTEGER(Integer.class), // 32-bit LONG(Long.class), // 64-bit FLOAT(Float.class), DOUBLE(Double.class), STRING(String.class), BINARY(byte[].class), // raw data NESTED(Map.class), // nested field ARRAY(List.class); // array field private final Class<?> javaType; Type(Class<?> javaType) { this.javaType = javaType; } public Class<?> getJavaType() { return javaType; } public boolean valueOfSameType(Object value) throws ParserException { return value == null || this.equals(Schema.fromJavaType(value)); } /** * Determines the {@link Type} of the value specified * @param val value for which to determine the type * @return {@link Type} of the value */ public static Type getTypeOfVal(String val) { Type type = null; Type[] types = Type.values(); if (val.equalsIgnoreCase("true") || val.equalsIgnoreCase("false")) { type = BOOLEAN; } for (int i = 1; type == null && i < STRING.ordinal(); i++) { final Class clazz = types[i].getJavaType(); try { Object result = clazz.getMethod("valueOf", String.class).invoke(null, val); // temporary workaround to work for Double as Double get parsed as Float with value infinity if (!(result instanceof Float) || !((Float) result).isInfinite()) { type = types[i]; break; } } catch (Exception e) { /* Exception is thrown if type does not match. Ignore to search next type */ } } if (type == null) { type = STRING; } return type; } } /** * A custom JsonTypeIdResolver that uses the Field.Type property to deserialize * to the correct Schema.Field and/or its sub-classes. */ static class SchemaJsonTypeIdResolver implements TypeIdResolver { private JavaType baseType; @Override public void init(JavaType javaType) { baseType = javaType; } @Override public String idFromValue(Object o) { return idFromValueAndType(o, o.getClass()); } @Override public String idFromValueAndType(Object o, Class<?> aClass) { return null; } @Override public String idFromBaseType() { return idFromValueAndType(null, baseType.getRawClass()); } @Override public JavaType typeFromId(String s) { return typeFromId(null, s); } @Override public JavaType typeFromId(DatabindContext databindContext, String s) { Type fieldType = Schema.Type.valueOf(s); JavaType javaType; switch (fieldType) { case NESTED: javaType = TypeFactory.defaultInstance().constructType(NestedField.class); break; case ARRAY: javaType = TypeFactory.defaultInstance().constructType(ArrayField.class); break; default: javaType = TypeFactory.defaultInstance().constructType(Field.class); } return javaType; } @Override public String getDescForKnownTypeIds() { return null; } @Override public JsonTypeInfo.Id getMechanism() { return JsonTypeInfo.Id.CUSTOM; } } @JsonTypeInfo(use= JsonTypeInfo.Id.CUSTOM, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true) @JsonTypeIdResolver(SchemaJsonTypeIdResolver.class) public static class Field implements Serializable { String name; Type type; boolean optional; // for jackson public Field() { } public Field(Field other) { name = other.getName(); type = other.getType(); optional = other.isOptional(); } public Field copy() { return new Field(this); } public static Field of(String name, Type type) { return new Field(name, type); } public static Field optional(String name, Type type) { return new Field(name, type, true); } // TODO: make it private after refactoring the usages public Field(String name, Type type){ this(name, type, false); } private Field(String name, Type type, boolean optional){ this.name = name; this.type = type; this.optional = optional; } public String getName(){ return this.name; } public Type getType(){ return this.type; } public boolean isOptional() { return optional; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Field field = (Field) o; if (optional != field.optional) return false; if (name != null ? !name.equals(field.name) : field.name != null) return false; return type == field.type; } @Override public int hashCode() { int result = name != null ? name.hashCode() : 0; result = 31 * result + (type != null ? type.hashCode() : 0); result = 31 * result + (optional ? 1 : 0); return result; } @Override public String toString() { return "Field{" + "name='" + name + '\'' + ", type=" + type + ", optional=" + optional + '}'; } // Input should be of the form: name='deviceId', type=LONG, optional public static Field fromString(String str) { String[] nameTypePair = str.split(","); String name = removePrimeSymbols(nameTypePair[0].split("=")[1]); String val = removePrimeSymbols(nameTypePair[1].split("=")[1]); boolean optional = nameTypePair.length >= 3 && nameTypePair[2].equalsIgnoreCase("optional"); return new Field(name, Type.valueOf(val), optional); } // Removes the prime symbols that are in the beginning and end of the String, // e.g. 'device', device', 'device will be converted to device private static String removePrimeSymbols(String in) { return in.replaceAll("'?(\\w+)'?","$1"); } } /** * A builder for constructing the schema from fields. */ public static class SchemaBuilder { private final List<Field> fields = new ArrayList<>(); public SchemaBuilder field(Field field) { fields.add(field); return this; } public SchemaBuilder fields(Field... fields) { Collections.addAll(this.fields, fields); return this; } public SchemaBuilder fields(List<Field> listOfFields) { this.fields.addAll(listOfFields); return this; } public Schema build() { if(fields.isEmpty()) { throw new IllegalArgumentException("Schema with empty fields!"); } return new Schema(fields); } } /** * A composite type for representing nested types. */ @JsonInclude(JsonInclude.Include.NON_NULL) public static class NestedField extends Field { private String namespace; private List<Field> fields; public static NestedField of(String name, List<Field> fields) { return new NestedField(name, fields); } public static NestedField of(String name, String namespace, Field... fields) { return new NestedField(name, namespace, Arrays.asList(fields), false); } public static NestedField of(String name, Field... fields) { return new NestedField(name, Arrays.asList(fields)); } public static NestedField optional(String name, List<Field> fields) { return new NestedField(name, fields, true); } public static NestedField optional(String name, String namespace, Field... fields) { return new NestedField(name, namespace, Arrays.asList(fields), true); } public static NestedField optional(String name, Field... fields) { return new NestedField(name, Arrays.asList(fields), true); } private NestedField() {} private NestedField(String name, List<Field> fields) { this(name, fields, false); } private NestedField(String name, List<Field> fields, boolean optional) { this(name, null, fields, optional); } private NestedField(String name, String namespace, List<Field> fields, boolean optional) { super(name, Type.NESTED, optional); this.namespace = namespace; this.fields = ImmutableList.copyOf(fields); } public NestedField(NestedField other) { super(other); if (other.fields != null) { fields = other.fields.stream().map(Field::copy).collect(Collectors.toList()); } } public NestedField copy() { return new NestedField(this); } public List<Field> getFields() { return fields; } public String getNamespace() { return namespace; } @Override public String toString() { return "NestedField{" + "namespace='" + namespace + '\'' + ", fields=" + fields + '}' + super.toString(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; NestedField that = (NestedField) o; if (namespace != null ? !namespace.equals(that.namespace) : that.namespace != null) return false; return fields != null ? fields.equals(that.fields) : that.fields == null; } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + (namespace != null ? namespace.hashCode() : 0); result = 31 * result + (fields != null ? fields.hashCode() : 0); return result; } } /** * A composite type that specifically represents an array or sequence of fields. */ public static class ArrayField extends Field { /* * if members is a singleton it represents a homogeneous array of that type (e.g. Array[String]) * if not a heterogeneous array like a JSON array. */ private List<Field> members; public static ArrayField of(String name, List<Field> fields) { return new ArrayField(name, fields); } public static ArrayField of(String name, Field... fields) { return new ArrayField(name, Arrays.asList(fields)); } public static ArrayField optional(String name, List<Field> fields) { return new ArrayField(name, fields, true); } public static ArrayField optional(String name, Field... fields) { return new ArrayField(name, Arrays.asList(fields), true); } // for jackson private ArrayField() { } private ArrayField(String name, List<Field> members) { this(name, members, false); } private ArrayField(String name, List<Field> members, boolean optional) { super(name, Type.ARRAY, optional); this.members = ImmutableList.copyOf(members); } public ArrayField(ArrayField other) { super(other); if (other.members != null) { members = other.members.stream().map(Field::copy).collect(Collectors.toList()); } } public ArrayField copy() { return new ArrayField(this); } public List<Field> getMembers() { return members; } @JsonIgnore public boolean isHomogenous() { return members != null && members.size() == 1; } @Override public String toString() { return "ArrayField{" + "name='" + name + '\'' + "members=" + members + "} "; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; ArrayField that = (ArrayField) o; return !(members != null ? !members.equals(that.members) : that.members != null); } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + (members != null ? members.hashCode() : 0); return result; } } private final Map<String, Field> fields = new LinkedHashMap<>(); // for jackson public Schema() { } // use the static factory or the builder private Schema(List<Field> fields){ setFields(fields); } /** * Construct a new Schema of the given fields. */ public static Schema of(Field... fields) { return new SchemaBuilder().fields(fields).build(); } /** * Construct a new Schema of the given list of fields. */ public static Schema of(List<Field> fields) { return new SchemaBuilder().fields(fields).build(); } public static Schema unionOf(Schema first, Schema second) { List<Field> fields = new ArrayList<>(); fields.addAll(first.getFields()); fields.addAll(second.getFields()); return new Schema(fields); } // for jackson public void setFields(List<Field> fields) { for (Field field: fields) { this.fields.put(field.getName().toUpperCase(), field); } } public List<Field> getFields(){ return new ArrayList<>(this.fields.values()); } /** * Returns a field in the schema with the given name or null * if the schema does not contain the field with the name. */ public Field getField(String name) { return fields.get(name.toUpperCase()); } //TODO: need to replace with actual ToJson from Json //TODO: this can be simplified to fields.toString() a public String toString() { if(fields == null) return "null"; if(fields.isEmpty()) return "{}"; StringBuilder sb = new StringBuilder(); sb.append("{"); for(Field field : fields.values()) { sb.append(field.toString()).append(","); } sb.setLength(sb.length() -1 ); // remove last, orphan ',' return sb.append("}").toString(); } // input received is typically of the form {{name='deviceId', type=LONG},{name='deviceName', type=STRING},} public static Schema fromString(String str) { if (str.equals("null")) { return null; } if (str.equals("{}")) { return new Schema(new ArrayList<Field>()); } str = str.replace(",}", ","); // remove the last orphan ',' in inputs such as {{name='deviceName', type=STRING},} str = str.replace("{", ""); str = str.replace("{", ""); str = str.replace("}}", ""); // remove }} at the end of the String String[] split = str.split("},"); List<Field> fields = new ArrayList<>(); for(String fieldStr : split) { fields.add(Field.fromString(fieldStr)); } return new Schema(fields); } /** * Constructs a schema object from a map of sample data. * * @param parsedData * @return * @throws ParserException */ public static Schema fromMapData(Map<String, Object> parsedData) throws ParserException { List<Field> fields = parseFields(parsedData); return new SchemaBuilder().fields(fields).build(); } private static List<Field> parseFields(Map<String, Object> fieldMap) throws ParserException { List<Field> fields = new ArrayList<>(); for(Map.Entry<String, Object> entry: fieldMap.entrySet()) { fields.add(parseField(entry.getKey(), entry.getValue())); } return fields; } private static Field parseField(String fieldName, Object fieldValue) throws ParserException { Field field = null; Type fieldType = fromJavaType(fieldValue); if(fieldType == Type.NESTED) { field = new NestedField(fieldName, parseFields((Map<String, Object>)fieldValue)); } else if(fieldType == Type.ARRAY) { Multiset<Field> members = parseArray((List<Object>)fieldValue); Set<Field> fieldTypes = members.elementSet(); if (fieldTypes.size() > 1) { field = new ArrayField(fieldName, new ArrayList<>(members)); } else if (fieldTypes.size() == 1) { field = new ArrayField(fieldName, new ArrayList<>(members.elementSet())); } else { throw new IllegalArgumentException("Array should have at least one element"); } } else { field = new Field(fieldName, fieldType); } return field; } private static Multiset<Field> parseArray(List<Object> array) throws ParserException { Multiset<Field> members = LinkedHashMultiset.create(); for(Object member: array) { members.add(parseField(null, member)); } return members; } //TODO: complete this and move into some parser utility class public static Type fromJavaType(Object value) throws ParserException { if(value instanceof String) { return Type.STRING; } else if (value instanceof Short) { return Type.SHORT; } else if (value instanceof Byte) { return Type.BYTE; } else if (value instanceof Float) { return Type.FLOAT; } else if (value instanceof Long) { return Type.LONG; } else if (value instanceof Double) { return Type.DOUBLE; } else if (value instanceof Integer) { return Type.INTEGER; } else if (value instanceof Boolean) { return Type.BOOLEAN; } else if (value instanceof byte[]) { return Type.BINARY; } else if (value instanceof List) { return Type.ARRAY; } else if (value instanceof Map) { return Type.NESTED; } throw new ParserException("Unknown type " + value.getClass()); } public static Type fromJavaType(Class<?> clazz) throws ParserException { if(clazz.equals(String.class)) { return Type.STRING; } else if (clazz.equals(Short.class)) { return Type.SHORT; } else if (clazz.equals(Byte.class)) { return Type.BYTE; } else if (clazz.equals(Float.class)) { return Type.FLOAT; } else if (clazz.equals(Long.class)) { return Type.LONG; } else if (clazz.equals(Double.class)) { return Type.DOUBLE; } else if (clazz.equals(Integer.class)) { return Type.INTEGER; } else if (clazz.equals(Boolean.class)) { return Type.BOOLEAN; } else if (clazz.equals(byte[].class)) { return Type.BINARY; } else if (clazz.equals(List.class)) { return Type.ARRAY; } else if (clazz.equals(Map.class)) { return Type.NESTED; } throw new ParserException("Unknown type " + clazz); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Schema schema = (Schema) o; return !(fields != null ? !fields.equals(schema.fields) : schema.fields != null); } @Override public int hashCode() { return fields != null ? fields.hashCode() : 0; } }