/*
* Copyright 2010 Outerthought bvba
*
* 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 org.lilyproject.util.repo;
import java.io.IOException;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import org.apache.commons.lang.builder.EqualsBuilder;
import org.apache.commons.lang.builder.HashCodeBuilder;
import org.codehaus.jackson.JsonEncoding;
import org.codehaus.jackson.JsonGenerator;
import org.codehaus.jackson.JsonParser;
import org.codehaus.jackson.JsonToken;
import org.codehaus.jackson.util.ByteArrayBuilder;
import org.lilyproject.repository.api.IdGenerator;
import org.lilyproject.repository.api.Record;
import org.lilyproject.repository.api.SchemaId;
import org.lilyproject.util.ObjectUtils;
import org.lilyproject.util.json.JsonFormat;
/**
* Represents the payload of an event about a create-update-delete operation on the repository.
*
* <p>The actual payload is json, this class helps in parsing or constructing that json.
*/
public class RecordEvent {
private long versionCreated = -1;
private long versionUpdated = -1;
private Type type;
private String tableName;
private Set<SchemaId> updatedFields;
private boolean recordTypeChanged = false;
/** For index-type events: affected vtags */
private Set<SchemaId> vtagsToIndex;
private IndexRecordFilterData indexRecordFilterData;
/** A copy of the attributes supplied via {@link Record#setAttributes(Map)}. */
private Map<String, String> attributes;
public enum Type {
CREATE("repo:record-created"),
UPDATE("repo:record-updated"),
DELETE("repo:record-deleted"),
INDEX("repo:index");
private String name;
private Type(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
public RecordEvent() {
}
/**
* Creates a record event from the json data supplied as bytes.
*/
public RecordEvent(byte[] data, IdGenerator idGenerator) throws IOException {
// Using streaming JSON parsing for performance. We expect the JSON to be correct, validation
// is absent/minimal.
JsonParser jp = JsonFormat.JSON_FACTORY.createJsonParser(data);
JsonToken current;
current = jp.nextToken();
if (current != JsonToken.START_OBJECT) {
throw new RuntimeException("Not a JSON object.");
}
while (jp.nextToken() != JsonToken.END_OBJECT) {
String fieldName = jp.getCurrentName();
current = jp.nextToken(); // move from field name to field value
if (fieldName.equals("type")) {
String messageType = jp.getText();
if (messageType.equals(Type.CREATE.getName())) {
type = Type.CREATE;
} else if (messageType.equals(Type.DELETE.getName())) {
type = Type.DELETE;
} else if (messageType.equals(Type.UPDATE.getName())) {
type = Type.UPDATE;
} else if (messageType.equals(Type.INDEX.getName())) {
type = Type.INDEX;
} else {
throw new RuntimeException("Unexpected kind of message type: " + messageType);
}
} else if (fieldName.equals("tableName")) {
this.tableName = jp.getText();
} else if (fieldName.equals("versionCreated")) {
versionCreated = jp.getLongValue();
} else if (fieldName.equals("versionUpdated")) {
versionUpdated = jp.getLongValue();
} else if (fieldName.equals("recordTypeChanged")) {
recordTypeChanged = jp.getBooleanValue();
} else if (fieldName.equals("updatedFields")) {
if (current != JsonToken.START_ARRAY) {
throw new RuntimeException("updatedFields is not a JSON array");
}
while (jp.nextToken() != JsonToken.END_ARRAY) {
addUpdatedField(idGenerator.getSchemaId(jp.getBinaryValue()));
}
} else if (fieldName.equals("vtagsToIndex")) {
if (current != JsonToken.START_ARRAY) {
throw new RuntimeException("vtagsToIndex is not a JSON array");
}
while (jp.nextToken() != JsonToken.END_ARRAY) {
addVTagToIndex(idGenerator.getSchemaId(jp.getBinaryValue()));
}
} else if (fieldName.equals("attributes")) {
if (current != JsonToken.START_OBJECT) {
throw new RuntimeException("Attributes is not a JSON object");
}
this.attributes = new HashMap<String, String>();
while (jp.nextToken() != JsonToken.END_OBJECT) {
String key = jp.getCurrentName();
String value = jp.getText();
attributes.put(key, value);
}
} else if (fieldName.equals("indexFilterData")) {
this.indexRecordFilterData = new IndexRecordFilterData(jp, idGenerator);
}
}
}
public long getVersionCreated() {
return versionCreated;
}
public void setVersionCreated(long versionCreated) {
this.versionCreated = versionCreated;
}
public long getVersionUpdated() {
return versionUpdated;
}
public void setVersionUpdated(long versionUpdated) {
this.versionUpdated = versionUpdated;
}
public void setTableName(String tableName) {
this.tableName = tableName;
}
public String getTableName() {
return tableName;
}
/**
* Indicates if the record type of the non-versioned scope changed as part of this event.
* Should return false for newly created records.
*/
public boolean getRecordTypeChanged() {
return recordTypeChanged;
}
public void setRecordTypeChanged(boolean recordTypeChanged) {
this.recordTypeChanged = recordTypeChanged;
}
public Type getType() {
return type;
}
public void setType(Type type) {
this.type = type;
}
/**
* The fields which were updated (= added, deleted or changed), identified by their FieldType ID.
*
* <p>In case of a delete event, this list is empty.
*/
public Set<SchemaId> getUpdatedFields() {
return updatedFields != null ? updatedFields : Collections.<SchemaId>emptySet();
}
public void addUpdatedField(SchemaId fieldTypeId) {
if (updatedFields == null) {
updatedFields = new HashSet<SchemaId>();
}
updatedFields.add(fieldTypeId);
}
public Set<SchemaId> getVtagsToIndex() {
return vtagsToIndex;
}
public void addVTagToIndex(SchemaId vtag) {
if (vtagsToIndex == null) {
vtagsToIndex = new HashSet<SchemaId>();
}
vtagsToIndex.add(vtag);
}
/**
* Transient attributes passed on from the Record during create/update operations,
* see also {@link Record#setAttributes(Map)}.
*
* @return A map of Strings containing attributes.
*/
public Map<String,String> getAttributes() {
if (this.attributes == null) {
this.attributes = new HashMap<String,String>();
}
return this.attributes;
}
public boolean hasAttributes() {
return attributes != null && attributes.size() > 0;
}
/**
* Transient attributes passed on from the Record during create/update operations,
* see also {@link Record#setAttributes(Map)}.
*
* @param attributes A map of Strings containing attributes.
*/
public void setAttributes(Map<String,String> attributes) {
this.attributes = attributes;
}
public IndexRecordFilterData getIndexRecordFilterData() {
return indexRecordFilterData;
}
public void setIndexRecordFilterData(IndexRecordFilterData indexRecordFilterData) {
this.indexRecordFilterData = indexRecordFilterData;
}
public void toJson(JsonGenerator gen) throws IOException {
gen.writeStartObject();
if (type != null) {
gen.writeStringField("type", type.getName());
}
if (tableName != null) {
gen.writeStringField("tableName", tableName);
}
if (versionUpdated != -1) {
gen.writeNumberField("versionUpdated", versionUpdated);
}
if (versionCreated != -1) {
gen.writeNumberField("versionCreated", versionCreated);
}
if (recordTypeChanged) {
gen.writeBooleanField("recordTypeChanged", true);
}
if (updatedFields != null && updatedFields.size() > 0) {
gen.writeArrayFieldStart("updatedFields");
for (SchemaId updatedField : updatedFields) {
gen.writeBinary(updatedField.getBytes());
}
gen.writeEndArray();
}
if (vtagsToIndex != null && vtagsToIndex.size() > 0) {
gen.writeArrayFieldStart("vtagsToIndex");
for (SchemaId vtag : vtagsToIndex) {
gen.writeBinary(vtag.getBytes());
}
gen.writeEndArray();
}
if (attributes != null && attributes.size() > 0) {
gen.writeObjectFieldStart("attributes");
for(String key : attributes.keySet()) {
gen.writeStringField(key, attributes.get(key));
}
gen.writeEndObject();
}
if (indexRecordFilterData != null) {
gen.writeFieldName("indexFilterData");
indexRecordFilterData.toJson(gen);
}
gen.writeEndObject();
gen.flush();
}
public String toJson() {
try {
StringWriter writer = new StringWriter();
JsonGenerator gen = JsonFormat.JSON_FACTORY.createJsonGenerator(writer);
toJson(gen);
return writer.toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public byte[] toJsonBytes() {
try {
ByteArrayBuilder bb = new ByteArrayBuilder(JsonFormat.JSON_FACTORY._getBufferRecycler());
JsonGenerator gen = JsonFormat.JSON_FACTORY.createJsonGenerator(bb, JsonEncoding.UTF8);
toJson(gen);
byte[] result = bb.toByteArray();
bb.release();
return result;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
RecordEvent other = (RecordEvent)obj;
if (other.type != this.type) {
return false;
}
if (other.recordTypeChanged != this.recordTypeChanged) {
return false;
}
if (other.versionCreated != this.versionCreated) {
return false;
}
if (other.versionUpdated != this.versionUpdated) {
return false;
}
if (!ObjectUtils.safeEquals(other.updatedFields, this.updatedFields)) {
return false;
}
if (!ObjectUtils.safeEquals(other.vtagsToIndex, this.vtagsToIndex)) {
return false;
}
if(!ObjectUtils.safeEquals(other.attributes, this.attributes)) {
return false;
}
if (!ObjectUtils.safeEquals(other.tableName, tableName)) {
return false;
}
// TODO implement equals for IndexRecordFilterData
return true;
}
@Override
public int hashCode() {
int result = (int) (versionCreated ^ (versionCreated >>> 32));
result = 31 * result + (int) (versionUpdated ^ (versionUpdated >>> 32));
result = 31 * result + (type != null ? type.hashCode() : 0);
result = 31 * result + (updatedFields != null ? updatedFields.hashCode() : 0);
result = 31 * result + (recordTypeChanged ? 1 : 0);
result = 31 * result + (vtagsToIndex != null ? vtagsToIndex.hashCode() : 0);
result = 31 * result + (attributes != null ? attributes.hashCode() : 0);
return result;
}
/**
* Data needed for IndexRecordFilter evaluation.
*
* <p>Information needed to decide if an IndexRecordFilter matches a record. Contains both the
* necessary information both from the old and new record state, so that we know what matched
* before and what matches now, which enables important optimisations.</p>
*
* <p>For example, this information is used by the IndexerEditFilter to only sent events
* to sep consumers from relevant indexes, as well as by IndexUpdater to know what
* index inclusion rule matches before & now.</p>
*
* <p>At the time of this writing, the indexerconf only allows selection
* based on record type, on 1 field, and on information that is part of the record id.
* The model here is already a bit more flexible (can contain info on multiple fields) to allow for
* more powerful selections in the future.</p>
*/
public static class IndexRecordFilterData {
/**
* A subscription id set that represents as "all the index subscriptions".
*/
public static final Set<String> ALL_INDEX_SUBSCRIPTIONS = ImmutableSet.of("/");
private boolean newRecordExists;
private boolean oldRecordExists;
private SchemaId newRecordType;
private SchemaId oldRecordType;
private List<FieldChange> fieldChanges;
// All index subscriptions to be either included or excluded when indexing (see also includeSubcriptions)
private Set<String> indexSubscriptionIds;
// Flag to determine if the indexSubscriptions set is for inclusion (true) or exclusion (false)
private boolean includeSubscriptions = true;
public IndexRecordFilterData() {
}
public IndexRecordFilterData(JsonParser jp, IdGenerator idGenerator) throws IOException {
JsonToken current = jp.getCurrentToken();
if (current != JsonToken.START_OBJECT) {
throw new RuntimeException("Not a JSON object.");
}
while (jp.nextToken() != JsonToken.END_OBJECT) {
String fieldName = jp.getCurrentName();
current = jp.nextToken(); // move from field name to field value
if (fieldName.equals("old")) {
oldRecordExists = jp.getBooleanValue();
} else if (fieldName.equals("new")) {
newRecordExists = jp.getBooleanValue();
} else if (fieldName.equals("newRecordType")) {
newRecordType = idGenerator.getSchemaId(jp.getBinaryValue());
} else if (fieldName.equals("oldRecordType")) {
oldRecordType = idGenerator.getSchemaId(jp.getBinaryValue());
} else if (fieldName.equals("includeSubscriptions")) {
includeSubscriptions = jp.getBooleanValue();
} else if (fieldName.equals("fields")) {
if (current != JsonToken.START_ARRAY) {
throw new RuntimeException("updatedFields is not a JSON array");
}
fieldChanges = new ArrayList<FieldChange>();
while (jp.nextToken() != JsonToken.END_ARRAY) {
fieldChanges.add(new FieldChange(jp, idGenerator));
}
} else if (fieldName.equals("subscriptions")) {
if (current != JsonToken.START_ARRAY) {
throw new RuntimeException("subscriptions is not a JSON array");
}
indexSubscriptionIds = Sets.newHashSet();
while (jp.nextToken() != JsonToken.END_ARRAY) {
indexSubscriptionIds.add(jp.getText());
}
}
}
}
public boolean getNewRecordExists() {
return newRecordExists;
}
public void setNewRecordExists(boolean newRecordExists) {
this.newRecordExists = newRecordExists;
}
public boolean getOldRecordExists() {
return oldRecordExists;
}
public void setOldRecordExists(boolean oldRecordExists) {
this.oldRecordExists = oldRecordExists;
}
public SchemaId getNewRecordType() {
return newRecordType;
}
public void setNewRecordType(SchemaId newRecordType) {
this.newRecordType = newRecordType;
}
public SchemaId getOldRecordType() {
return oldRecordType;
}
public void setOldRecordType(SchemaId oldRecordType) {
this.oldRecordType = oldRecordType;
}
public void addChangedField(SchemaId id, byte[] oldValue, byte[] newValue) {
if (fieldChanges == null) {
fieldChanges = new ArrayList<FieldChange>();
}
fieldChanges.add(new FieldChange(id, oldValue, newValue));
}
public List<FieldChange> getFieldChanges() {
return fieldChanges;
}
public void toJson(JsonGenerator gen) throws IOException {
gen.writeStartObject();
gen.writeBooleanField("old", oldRecordExists);
gen.writeBooleanField("new", newRecordExists);
gen.writeBooleanField("includeSubscriptions", includeSubscriptions);
if (newRecordType != null) {
gen.writeBinaryField("newRecordType", newRecordType.getBytes());
}
if (oldRecordType != null) {
gen.writeBinaryField("oldRecordType", oldRecordType.getBytes());
}
if (fieldChanges != null) {
gen.writeArrayFieldStart("fields");
for (FieldChange fieldChange : fieldChanges) {
fieldChange.toJson(gen);
}
gen.writeEndArray();
}
if (indexSubscriptionIds != null) {
gen.writeArrayFieldStart("subscriptions");
for (String subscriptionId : indexSubscriptionIds) {
gen.writeString(subscriptionId);
}
gen.writeEndArray();
}
gen.writeEndObject();
}
/**
* Set the index subscription ids to be included when distributing the containing record
* event to indexers. This cannot be combined with exclusions.
*
* @param indexSubscriptionIds Ids of the index subscriptions for which the containing
* RecordEvent applies
*/
public void setSubscriptionInclusions(Set<String> indexSubscriptionIds) {
if (indexSubscriptionIds.equals(ALL_INDEX_SUBSCRIPTIONS)){
indexSubscriptionIds = null;
} else {
this.indexSubscriptionIds = indexSubscriptionIds;
}
includeSubscriptions = true;
}
/**
* Set the index subscription ids to be excluded when distributing the containing record
* event to indexers. This cannot be combined with inclusions.
*
* @param indexSubscriptionIds Ids of the index subscriptions for which the containing
* RecordEvent does not apply
*/
public void setSubscriptionExclusions(Set<String> indexSubscriptionIds) {
if (indexSubscriptionIds.equals(ALL_INDEX_SUBSCRIPTIONS)){
indexSubscriptionIds = null;
} else {
this.indexSubscriptionIds = indexSubscriptionIds;
}
includeSubscriptions = false;
}
/**
* Check if the containing RecordEvent is applicable to an index subscription.
*
* @param indexSubscriptionId Id of the index subscription to be checked
* @return true if the RecordEvent is applicable for the index subscription
*/
public boolean appliesToSubscription(String indexSubscriptionId) {
if (includeSubscriptions) {
return indexSubscriptionIds == null
|| indexSubscriptionIds.contains(indexSubscriptionId);
} else {
return indexSubscriptionIds != null
&& !indexSubscriptionIds.contains(indexSubscriptionId);
}
}
@Override
public boolean equals(Object obj) {
return EqualsBuilder.reflectionEquals(this, obj);
}
@Override
public int hashCode() {
return HashCodeBuilder.reflectionHashCode(this);
}
}
public static class FieldChange {
private SchemaId id;
private byte[] oldValue;
private byte[] newValue;
public FieldChange(SchemaId id, byte[] oldValue, byte[] newValue) {
this.id = id;
this.oldValue = oldValue;
this.newValue = newValue;
}
public FieldChange(JsonParser jp, IdGenerator idGenerator) throws IOException {
JsonToken current = jp.getCurrentToken();
if (current != JsonToken.START_OBJECT) {
throw new RuntimeException("Not a JSON object.");
}
while (jp.nextToken() != JsonToken.END_OBJECT) {
String fieldName = jp.getCurrentName();
current = jp.nextToken(); // move from field name to field value
if (fieldName.equals("id")) {
this.id = idGenerator.getSchemaId(jp.getBinaryValue());
} else if (fieldName.equals("old")) {
oldValue = jp.getBinaryValue();
} else if (fieldName.equals("new")) {
newValue = jp.getBinaryValue();
}
}
}
public SchemaId getId() {
return id;
}
public byte[] getOldValue() {
return oldValue;
}
public byte[] getNewValue() {
return newValue;
}
public void toJson(JsonGenerator gen) throws IOException {
gen.writeStartObject();
gen.writeBinaryField("id", id.getBytes());
if (oldValue != null) {
gen.writeBinaryField("old", oldValue);
}
if (newValue != null) {
gen.writeBinaryField("new", newValue);
}
gen.writeEndObject();
}
@Override
public boolean equals(Object obj) {
return EqualsBuilder.reflectionEquals(this, obj);
}
@Override
public int hashCode() {
return HashCodeBuilder.reflectionHashCode(this);
}
}
}