All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.cedarsoftware.ncube.Axis.groovy Maven / Gradle / Ivy

There is a newer version: 5.6.9
Show newest version
package com.cedarsoftware.ncube

import com.cedarsoftware.ncube.exception.AxisOverlapException
import com.cedarsoftware.ncube.exception.CoordinateNotFoundException
import com.cedarsoftware.ncube.proximity.LatLon
import com.cedarsoftware.ncube.proximity.Point3D
import com.cedarsoftware.util.CaseInsensitiveMap
import com.cedarsoftware.util.CompactCIHashMap
import com.cedarsoftware.util.CompactCILinkedMap
import com.cedarsoftware.util.MapUtilities
import com.cedarsoftware.util.io.JsonReader
import com.google.common.collect.RangeMap
import com.google.common.collect.TreeRangeMap
import groovy.transform.CompileStatic
import it.unimi.dsi.fastutil.ints.Int2ObjectRBTreeMap
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap
import it.unimi.dsi.fastutil.objects.Object2ObjectRBTreeMap

import java.util.regex.Matcher

import static com.cedarsoftware.ncube.ReferenceAxisLoader.*
import static com.cedarsoftware.util.Converter.convertToBigDecimal
import static com.cedarsoftware.util.Converter.convertToDate
import static com.cedarsoftware.util.Converter.convertToDouble
import static com.cedarsoftware.util.Converter.convertToLong
import static com.cedarsoftware.util.Converter.convertToString
import static com.cedarsoftware.util.EncryptionUtilities.calculateSHA1Hash
import static com.cedarsoftware.util.StringUtilities.hasContent
import static com.cedarsoftware.util.StringUtilities.isEmpty
import static java.lang.Math.abs

/**
 * Implements an Axis of an NCube. When modeling, think of an axis as a 'condition'
 * or decision point.  An input variable (like 'X:1' in a cartesian coordinate system)
 * is passed in, and the Axis's job is to locate the column that best matches the input,
 * as quickly as possible.
 *
 * Five types of axes are supported, DISCRETE, RANGE, SET, NEAREST, and RULE.
 * DISCRETE matches discrete values with .equals().  Locates items in O(1)
 * RANGE matches [low, high) values in O(Log n) time.
 * SET matches repeating DISCRETE and RANGE values in O(Log n) time.
 * NEAREST finds the column matching the closest value to the input.  Runs in O(Log n) for
 * Number and Date types, O(n) for String, Point2D, Point3D, LatLon.
 * RULE fires all conditions that evaluate to true.  Runs in O(n).
* * @author John DeRegnaucourt ([email protected]) *
* Copyright (c) Cedar Software LLC *

* 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. */ @CompileStatic class Axis { public static final String DONT_CARE = '_︿_ψ_☼' public static final int SORTED = 0 public static final int DISPLAY = 1 protected static final long BASE_AXIS_ID = 1000000000000L protected static final long MAX_COLUMN_ID = 2000000000L private String name private AxisType type private AxisValueType valueType protected Map metaProps = null private Column defaultCol protected long id private int preferredOrder = SORTED protected boolean fireAll = true private boolean isRef // Internal indexes private final transient Map idToCol = new Long2ObjectOpenHashMap<>() private final transient Map colNameToCol = new CompactCIHashMap<>() private final transient SortedMap displayOrder = new Int2ObjectRBTreeMap<>() private transient SortedMap valueToCol private transient RangeMap rangeToCol = TreeRangeMap.create() private static final ThreadLocal LOCAL_RANDOM = new ThreadLocal() { Random initialValue() { return new Random(System.nanoTime()) } } /** * Implement to provide data for this Axis */ static interface AxisRefProvider { void load(Axis axis) } // for construction during serialization private Axis() { id=0 } /** * Use this constructor for non-rule Axes. * @param name String Axis name * @param type AxisType (DISCRETE, RANGE, SET, NEAREST, RULE) * @param valueType AxisValueType (STRING, LONG, BIG_DECIMAL, DOUBLE, DATE, EXPRESSION, COMPARABLE) * @param hasDefault boolean set to true to have a Default column that will match when no other columns match * @param order SORTED or DISPLAY (insertion order) * @param id long id of Axis. Ask n-cube for max ID, then add 1 to it, and use that. * @param fireAll boolean if set to true, all conditions that evaluate to true will have their associated * statements executed. If set to false, the first condition that evaluates to true will be executed, but * then no conditions on the RULE axis will be evaluated. */ Axis(String name, AxisType type, AxisValueType valueType, boolean hasDefault, int order = SORTED, long id = 1, boolean fireAll = true) { isRef = false this.id = id this.name = name this.type = type preferredOrder = order this.fireAll = fireAll if (AxisType.RULE.is(type)) { preferredOrder = DISPLAY this.valueType = AxisValueType.EXPRESSION } else if (AxisType.NEAREST.is(type)) { preferredOrder = SORTED this.valueType = valueType defaultCol = null } else { this.valueType = valueType } this.valueToCol = createValueToColMap() if (hasDefault && type != AxisType.NEAREST) { defaultCol = new Column(null, defaultColId) indexColumn(defaultCol) } dropIrrelevantIndexes() } /** * Use this constructor to create a 'reference' axis. This allows a single MASTER DATA axis to be referenced * by many other axes without repeating the columnar data. * @param name String Axis name * @param id long id of Axis. Ask n-cube for max ID, then add 1 to it, and use that. * @param axisRefProvider implementer is expected to load(this), e.g. load this axis completely, setting * all fields, etc. */ Axis(String name, long id, boolean hasDefault, AxisRefProvider axisRefProvider) { this.name = name this.id = id isRef = true this.valueToCol = null // Ask the provider to load this axis up. axisRefProvider.load(this) if (hasDefault && type != AxisType.NEAREST) { defaultCol = new Column(null, defaultColId) indexColumn(defaultCol) } // Verify that the axis is indeed valid dropIrrelevantIndexes() if (!AxisValueType.values().contains(valueType)) { throw new IllegalStateException("AxisValueType not set, axis: ${name}") } if (preferredOrder != DISPLAY && preferredOrder != SORTED) { throw new IllegalStateException("preferred order not set, axis: ${name}") } } /** * Re-index the axis (optionally assign new ID to the axis). * @param newId long optional new axis id */ protected void reindex(long newId = getId()) { id = newId List columns = columns clearIndexes() long axisIdPart = BASE_AXIS_ID * id for (Column column : columns) { column.id = axisIdPart + column.id % BASE_AXIS_ID indexColumn(column) } } private void reloadReferenceAxis(String cubeName, Map props) { clearIndexes() ReferenceAxisLoader refAxisLoader = new ReferenceAxisLoader(cubeName, name, props) refAxisLoader.load(this) } protected updateMetaProperties(Map newMetaProperties, String cubeName, Closure dropOrphans) { // Backup meta-properties Map metaMap = new CaseInsensitiveMap<>(columns.size()) columns.each { Column column -> metaMap.put(column.id, column.metaProperties) } clearMetaProperties() addMetaProperties(newMetaProperties) if (isRef) { reloadReferenceAxis(cubeName, newMetaProperties) } Set colIds = new LinkedHashSet(columns.size()) columns.each { Column column -> colIds.add(column.id) } dropOrphans(colIds) // Restore meta-properties columns.each { Column column -> if (metaMap.containsKey(column.id)) { Map map = metaMap.get(column.id) column.addMetaProperties(map) } } } private void dropIrrelevantIndexes() { if (AxisType.RULE.is(type)) { rangeToCol = null valueToCol = null } else if (AxisType.DISCRETE.is(type) || AxisType.NEAREST.is(type)) { rangeToCol = null } else if (AxisType.RANGE.is(type) || AxisType.SET.is(type)) { valueToCol = null } else { throw new IllegalArgumentException("Unknown axis type: ${type}") } } /** * @return ApplicationID of the referenced cube (if this Axis is a reference Axis, or * null otherwise). */ ApplicationID getReferencedApp() { String status = (getMetaProperty(REF_STATUS) as String) ?: ReleaseStatus.RELEASE String branch = (getMetaProperty(REF_BRANCH) as String) ?: ApplicationID.HEAD return isRef ? new ApplicationID(getMetaProperty(REF_TENANT) as String, getMetaProperty(REF_APP) as String, getMetaProperty(REF_VERSION) as String, status, branch) : null } /** * @return String name of referenced cube (or null if this is not a reference axis) */ String getReferenceCubeName() { return getMetaProperty(REF_CUBE_NAME) } /** * @return String name of referenced cube axis (or null if this is not a reference axis) */ String getReferenceAxisName() { return getMetaProperty(REF_AXIS_NAME) } /** * @return ApplicationID of the transformer cube (if this Axis is a reference Axis and it * specifies a transformer cube, otherwise null). */ ApplicationID getTransformApp() { String status = (getMetaProperty(TRANSFORM_STATUS) as String) ?: ReleaseStatus.RELEASE String branch = (getMetaProperty(TRANSFORM_BRANCH) as String) ?: ApplicationID.HEAD return referenceTransformed ? new ApplicationID(getMetaProperty(REF_TENANT) as String, getMetaProperty(TRANSFORM_APP) as String, getMetaProperty(TRANSFORM_VERSION) as String, status, branch) : null } /** * @return String name of referenced cube (or null if this is not a reference axis) */ String getTransformCubeName() { return getMetaProperty(TRANSFORM_CUBE_NAME) } /** * @return boolean true if this Axis is a reference Axis AND there is a transformer app * specified for the reference. */ boolean isReferenceTransformed() { String status = (getMetaProperty(TRANSFORM_STATUS) as String) ?: ReleaseStatus.RELEASE String branch = (getMetaProperty(TRANSFORM_BRANCH) as String) ?: ApplicationID.HEAD return isRef && hasContent(getMetaProperty(TRANSFORM_APP) as String) && hasContent(getMetaProperty(TRANSFORM_VERSION) as String) && hasContent(status) && hasContent(branch) && hasContent(transformCubeName) } /** * @return boolean true if this Axis is a reference to another axis, not a 'real' axis. A reference axis * cannot be modified. */ boolean isReference() { return isRef } /** * Break the reference to the other axis. After calling this method, this axis will * be a copy of the axis to which it had pointed. */ void breakReference() { isRef = false removeMetaProperty(REF_TENANT) removeMetaProperty(REF_APP) removeMetaProperty(REF_VERSION) removeMetaProperty(REF_STATUS) removeMetaProperty(REF_BRANCH) removeMetaProperty(REF_CUBE_NAME) removeMetaProperty(REF_AXIS_NAME) removeTransform() } void makeReference(ApplicationID refAppId, String refCubeName, refAxisName) { isRef = true Map args = [ (REF_TENANT): refAppId.tenant, (REF_APP): refAppId.app, (REF_VERSION): refAppId.version, (REF_STATUS): refAppId.status, (REF_BRANCH): refAppId.branch, (REF_CUBE_NAME): refCubeName, (REF_AXIS_NAME): refAxisName ] addMetaProperties(args) reloadReferenceAxis(refCubeName, args) } /** * Remove transform from reference axis. */ void removeTransform() { removeMetaProperty(TRANSFORM_APP) removeMetaProperty(TRANSFORM_VERSION) removeMetaProperty(TRANSFORM_STATUS) removeMetaProperty(TRANSFORM_BRANCH) removeMetaProperty(TRANSFORM_CUBE_NAME) } /** * @return long next id for use on a new Column. */ protected long getNextColId() { long baseAxisId = id * BASE_AXIS_ID Random random = LOCAL_RANDOM.get() long uniqueId = random.nextLong() long total = uniqueId % MAX_COLUMN_ID while (uniqueId <= 0L || idToCol.containsKey(baseAxisId + total)) { uniqueId = random.nextLong() total = uniqueId % MAX_COLUMN_ID } return baseAxisId + total } protected long getDefaultColId() { return id * BASE_AXIS_ID + Integer.MAX_VALUE } /** * @return Map (case insensitive keys) containing meta (additional) properties for the n-cube. */ Map getMetaProperties() { Map ret = metaProps == null ? new CompactCILinkedMap() : metaProps return Collections.unmodifiableMap(ret) } /** * Set (add / overwrite) a Meta Property associated to this axis. * @param key String key name of meta property * @param value Object value to associate to key * @return prior value associated to key or null if none was associated prior */ Object setMetaProperty(String key, Object value) { if (metaProps == null) { metaProps = new CompactCILinkedMap<>() } return metaProps.put(key, value) } /** * Fetch the value associated to the passed in Key from the MetaProperties (if any exist). If * none exist, null is returned. */ Object getMetaProperty(String key) { if (metaProps == null) { return null } return metaProps.get(key) } /** * Remove a meta-property entry */ Object removeMetaProperty(String key) { if (metaProps == null) { return null } Object value = metaProps.remove(key); if (metaProps.isEmpty()) { metaProps = null; } return value; } /** * Add a Map of meta properties all at once. * @param props Map of meta properties to add */ void addMetaProperties(Map props) { if (metaProps == null) { metaProps = new CompactCILinkedMap<>() } metaProps.putAll(props) } /** * Remove all meta properties associated to this Axis. */ void clearMetaProperties() { if (metaProps != null) { metaProps.clear() metaProps = null } } /** * @return boolean true if this RULE axis is set to fire all conditions that evaluate to true, false otherwise. */ boolean isFireAll() { return fireAll } /** * Set the 'fire-all' mode - true to have all conditions that evaluate to true have the associated cell execute, * false for only the first condition that is true to have it's associated cell execute. */ void setFireAll(boolean fireAll) { this.fireAll = fireAll } /** * Use Column id to retrieve column (hash map lookup), O(1) */ Column getColumnById(long colId) { return idToCol.get(colId) } /** * Index the passed in column. It is expected the Column value, id, name (optional), and display ID are * already established. */ private void indexColumn(Column column) { // 1: Index columns by ID idToCol.put(column.id, column) // 2: Index columns by name (if they have one) - held in CompactCIHashMap String colName = column.columnName if (hasContent(colName)) { colNameToCol.put(colName, column) } // 3. Index column by value if (column.value == null) { // No value to index (default column) AND do not add to displayOrder below return } // 4. Index columns by display order int order = column.displayOrder if (displayOrder.containsKey(column.displayOrder)) { // collision - move it to end order = displayOrder.lastKey() + 1 column.displayOrder = order } displayOrder.put(order, column) if (AxisType.DISCRETE.is(type) || AxisType.NEAREST.is(type)) { valueToColumnMap.put(standardizeColumnValue(column.value), column) } else if (AxisType.RANGE.is(type)) { rangeToCol.put(valueToRange(column.value), column) } else if (AxisType.SET.is(type)) { RangeSet set = (RangeSet)column.value final int len = set.size() for (int i=0; i < len; i++) { Comparable elem = set.get(i) rangeToCol.put(valueToRange(elem), column) } } } /** * @return long id of this Axis */ long getId() { return id } /** * Set long id of this Axis */ void setId(long id) { this.id = id } /** * @return String version of axis properties. Useful for SHA-1 computations. */ String getAxisPropString() { StringBuilder s = new StringBuilder() s.append('Axis: ') s.append(name) s.append(' [') s.append(type) s.append(', ') s.append(valueType) s.append(hasDefaultColumn() ? ', default-column' : ', no-default-column') s.append(SORTED == preferredOrder ? ', sorted' : ', unsorted') s.append(']') return s.toString() } String toString() { StringBuilder s = new StringBuilder(axisPropString) if (!MapUtilities.isEmpty(metaProps)) { s.append('\n') s.append(" metaProps: ${metaProps}") } return s.toString() } /** * @return String Axis name. The name is the value that String keys match (bind to) on the input. Although * the case of the name is maintained, it is compared case-insensitively. */ String getName() { return name } protected void setName(String name) { this.name = name } /** * @return AxisType of this Axis, which is one of: DISCRETE, RANGE, SET, NEAREST, RULE */ AxisType getType() { return type } protected void setType(AxisType newType) { type = newType } /** * @return AxisValueType of this Axis, which is one of: STRING, LONG, BIG_DECIMAL, DOUBLE, DATE, EXPRESSION, COMPARABLE */ AxisValueType getValueType() { return valueType } protected void setValueType(AxisValueType newValueType) { valueType = newValueType Map existing = valueToCol valueToCol = createValueToColMap() if (existing) { valueToCol.putAll(existing) } } private Map getValueToColumnMap() { if (valueToCol == null) { valueToCol = createValueToColMap() } return valueToCol } private SortedMap createValueToColMap() { if (AxisType.NEAREST.is(type)) { // TreeMap needed for Navigable interface valueToCol = AxisValueType.CISTRING.is(valueType) ? new TreeMap(String.CASE_INSENSITIVE_ORDER) : new TreeMap() } else { // Only need Sorted interface. If we use a HashMap below, findColumn() is faster, but getColumns() is 3x slower (must sort). valueToCol = AxisValueType.CISTRING.is(valueType) ? new Object2ObjectRBTreeMap(String.CASE_INSENSITIVE_ORDER) : new Object2ObjectRBTreeMap() } return valueToCol } protected void clearIndexes() { idToCol.clear() colNameToCol.clear() displayOrder.clear() valueToCol?.clear() rangeToCol?.clear() } /** * Given the passed in 'raw' value, get a Column from the passed in value, which entails * converting the 'raw' value to the correct type, promoting the value to the appropriate * internal value for comparison, and so on. * @param value Comparable typically a primitive, but can also be an n-cube Range, RangeSet, CommandCell, * or 2D, 3D, or LatLon. * @param suggestedId Long suggested column ID. Can be null or 0, in which case an ID will be generated. If not, * then the ID will be used (only the column portion, not the Axis ID portion). If that matches an existing ID * on the Axis, then an ID will be generated. * @return a Column with the up-promoted value as the column's value, and a unique ID on the column. If * the original value is a Range or RangeSet, the components in the Range or RangeSet are also up-promoted. */ protected Column createColumnFromValue(Comparable value, Long suggestedId, Map metaProperties = null) { value = standardizeColumnValue(value) if (suggestedId != null && suggestedId > 0 && value != null) { long attemptId = (id * BASE_AXIS_ID) + (suggestedId % BASE_AXIS_ID) if (idToCol.containsKey(attemptId)) { long colId = getValueBasedColumnId(value) attemptId = idToCol.containsKey(colId) ? nextColId : colId } return new Column(value, attemptId, metaProperties) } else { return new Column(value, value == null ? defaultColId : nextColId, metaProperties) } } private long getValueBasedColumnId(Comparable value) { String s = value == null ? '' : value.toString() long hash = abs(calculateSHA1Hash(s.getBytes('UTF-8')).hashCode()) % MAX_COLUMN_ID hash += id * BASE_AXIS_ID return hash } /** * Will throw IllegalArgumentException if passed in value duplicates a value on this axis. */ private void ensureUnique(Comparable value) { if (value == null) { // Attempting to add Default column to axis if (hasDefaultColumn()) { throw new IllegalArgumentException("Cannot add default column to axis '${name}' because it already has a default column.") } if (AxisType.NEAREST.is(type)) { throw new IllegalArgumentException("Cannot add default column to NEAREST axis '${name}' as it would never be chosen.") } } else { if (AxisType.DISCRETE.is(type) || AxisType.NEAREST.is(type)) { if (valueToColumnMap.containsKey(value)) { throw new AxisOverlapException("Passed in value '${value}' matches a value already on axis '${name}'") } } else if (AxisType.RANGE.is(type)) { Range range = (Range)value if (doesOverlap(range)) { throw new AxisOverlapException("Passed in Range overlaps existing Range on axis: ${name}, value: ${value}") } } else if (AxisType.SET.is(type)) { RangeSet set = (RangeSet)value if (doesOverlap(set)) { throw new AxisOverlapException("Passed in RangeSet overlaps existing RangeSet on axis: ${name}, value: ${value}") } } else if (AxisType.RULE.is(type)) { if (!(value instanceof CommandCell)) { throw new IllegalArgumentException("Columns for RULE axis must be a CommandCell, axis: ${name}, value: ${value}") } } else { throw new IllegalStateException("New axis type added without complete support.") } } } /** * Add a new Column to this axis. It will be added at the end in terms of display order. If the * axis is SORTED, it will be returned in sorted order if getColumns() or getColumnsWithoutDefault() * are called. * @param value Comparable value to add to this Axis. * @param colName The name of the column (useful for Rule axes. Any column can be given a name). Optional. * @param suggestedId Long use the suggested ID if possible. This allows an axis to be recreated * from persistent storage and have the same IDs. Optional. * @return Column instanced created from the passed in value. */ Column addColumn(Comparable value, String colName = null, Long suggestedId = null, Map colMetaProps = null) { final Column column = createColumnFromValue(value, suggestedId, colMetaProps) if (hasContent(colName)) { column.columnName = colName } addColumnInternal(column) return column } /** * Add a Column from another Axis to this Axis. It will * attempt to use the ID that is already on the Column, ignoring * the Axis portion of the ID. If there is a conflict, it will * then use an ID deterministically generated from the value of * the column. * @param column Column to add * @return Column added - the ID may not be the same as the ID from * the Column passed in. */ Column addColumn(Column column) { final Column newColumn = createColumnFromValue(column.value, column.id, column.metaProperties) addColumnInternal(newColumn) return newColumn } protected Column addColumnInternal(Column column) { ensureUnique(column.value) if (column.value == null) { column.id = defaultColId // Safety check - should never happen defaultCol = column } if (AxisType.RULE.is(type) && !column.default) { String colName = column.columnName if (isEmpty(colName)) { throw new IllegalArgumentException('Rule name cannot be empty.') } if (findColumnByName(colName)) { throw new IllegalArgumentException("There is already a rule named: ${colName} on axis: ${name}.") } } // New columns are always added at the end in terms of displayOrder. int order = displayOrder.isEmpty() ? 1 : displayOrder.lastKey() + 1 column.displayOrder = column.default ? Integer.MAX_VALUE : order indexColumn(column) return column } /** * This method deletes a column from an Axis. It is intentionally package * scoped because there are two parts to deleting a column - this removes * the column from the Axis, the other part removes the Cells that reference * the column (that is within NCube). * @param value Comparable value used to identify the column to delete. * @return Column that was deleted, or null if no column would be deleted. */ protected Column deleteColumn(Comparable value) { Column col = findColumn(value) if (col == null) { // Not found. return null } return deleteColumnById(col.id) } protected Column deleteColumnById(long colId) { Column col = idToCol.get(colId) if (isRef && !col.default) { throw new IllegalStateException("You cannot delete non-default columns from a reference Axis, axis: ${name}") } if (col == null) { return null } if (col.default) { defaultCol = null } // Remove column from scaffolding removeColumnFromIndex(col) return col } private void removeColumnFromIndex(Column col) { // Remove from col id to column map idToCol.remove(col.id) if (col.columnName) { colNameToCol.remove(col.columnName) } displayOrder.remove(col.displayOrder) if (col.value == null || AxisType.RULE.is(type)) { // Default Column is not indexed by value/range (null), so we are done. // Rule columns are not indexed by value/range return } // Remove from 'value' storage if (AxisType.DISCRETE.is(type) || AxisType.NEAREST.is(type)) { // O(1) remove valueToColumnMap.remove(standardizeColumnValue(col.value)) } else if (AxisType.RANGE.is(type)) { // O(Log n) remove rangeToCol.remove(valueToRange(col.value)) } else if (AxisType.SET.is(type)) { // O(Log n) remove RangeSet set = (RangeSet) col.value Iterator i = set.iterator() while (i.hasNext()) { Comparable item = i.next() rangeToCol.remove(valueToRange(item)) } } else { throw new IllegalStateException("Unsupported axis type (${type}) for axis '${name}', trying to remove column from internal index") } } /** * Update (change) the value of an existing column. This entails not only * changing the value, but resorting the axis's columns (columns are always in * sorted order for quick retrieval). The display order of the columns is not * rebuilt, because the column is changed in-place (e.g., changing Mon to Monday * does not change it's display order.) * @param colId long Column ID to update * @param value 'raw' value to set into the new column (will be up-promoted). * @param order int (optional) new display order for column */ void updateColumn(long colId, Comparable value, String name = null, int order = -1i) { if (isRef) { throw new IllegalStateException("You cannot update columns on a reference Axis, axis: ${name}") } Column column = idToCol.get(colId) deleteColumnById(colId) if (hasContent(name)) { column.columnName = name } Column newColumn = createColumnFromValue(value, colId, column.metaProperties) // re-use ID & column meta-props ensureUnique(newColumn.value) // re-use displayOrder or take it from order arg newColumn.displayOrder = order == -1i ? column.displayOrder : order indexColumn(newColumn) } /** * Update (merge) columns on this Axis from the passed in Collection. Columns that exist on both axes, * will have their values updated. Columns that exist on this axis, but not exist in the 'newCols' * will be deleted (and returned as a Set of deleted Columns). Columns that exist in newCols but not * on this are new columns. * * NOTE: The columns field within the newCols axis are NOT in sorted order as they normally are * within the Axis class. Instead, they are in display order (this order is typically set forth by a UI). */ Set updateColumns(Collection newCols, boolean allowPositiveColumnIds = false) { if (isRef) { throw new IllegalStateException("You cannot update columns on a reference Axis, axis: ${name}") } Set colsToDelete = new LinkedHashSet<>() Map newColumnMap = [:] // Step 1. Map all columns from passed in Collection by ID for (Column col : newCols) { Column newColumn = createColumnFromValue(col.value, col.id, col.metaProperties) newColumnMap.put(col.id, newColumn) } // Step 2. Build list of columns that no longer exist (add to deleted list) // AND update existing columns that match by ID columns from the passed in DTO. List tempCol = columns Iterator i = tempCol.iterator() while (i.hasNext()) { Column col = i.next() if (newColumnMap.containsKey(col.id)) { // Update case - matches existing column Column newCol = newColumnMap.get(col.id) col.value = newCol.value Map metaProperties = newCol.metaProperties for (Map.Entry entry : metaProperties.entrySet()) { col.setMetaProperty(entry.key, entry.value) } } else { // Delete case - existing column id no longer found if (col.value != null) { colsToDelete.add(col.id as Long) i.remove() } } } clearIndexes() // Step 3. Save existing before clearing all columns Map existingColumns = [:] for (Column column : tempCol) { existingColumns.put(column.id, column) if (!column.default) { ensureUnique(column.value) indexColumn(column) } } int dispOrder = 1 // Step 4. Add new columns (they exist in the passed in newCols, but not in this Axis) and // set display order to match the columns coming in from the DTO axis (argument). for (Column col : newCols) { if (col.value == null) { // Skip Default column continue } long existingId = col.id if (allowPositiveColumnIds && !existingColumns.containsKey(existingId)) { Column newCol = addColumnInternal(newColumnMap[col.id]) newCol.displayOrder = dispOrder++ existingId = newCol.id existingColumns[existingId] = newCol } else { if (col.id < 0) { // Add case - negative id, add new column to 'columns' List. Column newCol = addColumnInternal(newColumnMap[col.id]) existingId = newCol.id existingColumns.put(existingId, newCol) } Column realColumn = existingColumns.get(existingId) if (realColumn == null) { throw new IllegalArgumentException('Columns to be added should have negative ID values.') } realColumn.displayOrder = dispOrder++ } } clearIndexes() for (Column col : existingColumns.values()) { indexColumn(col) } return colsToDelete } private static GroovyExpression createExpressionFromValue(String value) { value = value.trim() boolean isUrl = false boolean cache = false boolean madeChange while (true) { madeChange = false // Place in loop to allow url|cache|http://... OR cache|url|http://... OR url|http:// OR cache|http://... if (value.startsWith('url|')) { isUrl = true value = value.substring(4) madeChange = true } if (value.startsWith('cache|')) { cache = true value = value.substring(6) madeChange = true } if (!madeChange) { break } } if (isUrl) { return new GroovyExpression(null, value, cache) } return new GroovyExpression(value, null, cache) } private Range parseRange(String value) { value = value.trim() if (value.startsWith('[') && value.endsWith(']')) { // Remove surrounding brackets (1st and last characters) value = value[1..-2] } Matcher matcher = Regexes.rangePattern.matcher(value) if (matcher.matches()) { String one = matcher.group(1) String two = matcher.group(2) return new Range(trimQuotes(one), trimQuotes(two)) } else { throw new IllegalArgumentException("Value (${value}) cannot be parsed as a Range. Use [value1, value2], axis: ${name}") } } private Comparable parseSet(String value) { try { // input will always be comma delimited list of items and ranges (we add the final array brackets) value = '[' + value + ']' Object[] list = (Object[]) JsonReader.jsonToJava(value, [(JsonReader.USE_MAPS):true] as Map) final RangeSet set = new RangeSet() for (Object item : list) { if (item instanceof Object[]) { // Convert to Range Object[] subList = (Object[]) item if (subList.length != 2) { throw new IllegalArgumentException('Range inside set must have exactly two (2) entries.') } Range range = promoteRange(new Range((Comparable)subList[0], (Comparable)subList[1])) set.add(range) } else if (item == null) { throw new IllegalArgumentException('Set cannot have null value inside.') } else { set.add(promoteValue(valueType, item as Comparable)) } } if (set.size() > 0) { return set } throw new IllegalArgumentException("Value: ${value} cannot be parsed as a Set. Must have at least one element within the set, axis: ${name}") } catch (IllegalArgumentException e) { throw e } catch (Exception e) { throw new IllegalArgumentException("Value: ${value} cannot be parsed as a Set. Use v1, v2, [low, high], v3, ... , axis: ${name}", e) } } private static String trimQuotes(String value) { if (value.startsWith('"') && value.endsWith('"')) { return value[1..-2] } if (value.startsWith("'") && value.endsWith("'")) { return value[1..-2] } return value.trim() } /** * @return SORTED (0) or DISPLAY (1) which indicates whether the getColumns() and * getColumnsWithoutDefault() methods will return the columns in sorted order * or display order (user order). */ int getColumnOrder() { return preferredOrder } /** * Set the ordering for the axis. * @param order int SORTED (0) or DISPLAY (1). */ void setColumnOrder(int order) { preferredOrder = order } /** * @return int total number of columns on this axis. Default column (if present) counts as 1. */ int size() { return idToCol.size() } /** * This method takes the input value (could be Number, String, Range, etc.) * and 'promotes' it to the same type as the Axis. * @param value Comparable value to promote (to highest of it's type [e.g., short to long]) * @return Comparable promoted value. For example, a Long would be returned a * Byte value were passed in, and this was a LONG axis. */ Comparable standardizeColumnValue(Comparable value) { if (value == null) { return null } else if (AxisType.DISCRETE.is(type)) { return promoteValue(valueType, value) } else if (AxisType.RULE.is(type)) { if (value instanceof String) { return createExpressionFromValue(value as String) } if (!(value instanceof CommandCell)) { throw new IllegalArgumentException("Must only add CommandCell values to ${type} axis '${name}' - attempted to add: ${value.class.name}") } return value } else if (AxisType.RANGE.is(type)) { if (value instanceof String) { value = parseRange(value as String) } if (!(value instanceof Range)) { throw new IllegalArgumentException("Must only add Range values to ${type} axis '${name}' - attempted to add: ${value.class.name}") } return promoteRange((Range)value) } else if (AxisType.SET.is(type)) { if (value instanceof String) { return parseSet(value as String) } else if (value instanceof Range) { return new RangeSet(promoteRange(value as Range)) } else if (!(value instanceof RangeSet)) { return new RangeSet(promoteValue(valueType, value)) } RangeSet set = new RangeSet() Iterator i = (value as RangeSet).iterator() while (i.hasNext()) { Comparable val = i.next() if (val instanceof Range) { promoteRange((Range)val) } else { val = promoteValue(valueType, val) } set.add(val) } return set } else if (AxisType.NEAREST.is(type)) { // Standardizing a NEAREST axis entails ensuring conformity amongst values (must all be Point2D, LatLon, Date, Long, String, etc.) value = promoteValue(valueType, value) if ((defaultCol != null && size() == 1) || (defaultCol == null && size() == 0)) { // Empty - first column added return value } Column col = (Column) displayOrder.get(displayOrder.firstKey()) if (value.class != col.value.class) { throw new IllegalArgumentException("Value '${value.class.name}' cannot be added to axis '${name}' where the values are of type: ${col.value.class.name}") } return value // First value added does not need to be checked } else { throw new IllegalArgumentException("New AxisType added '${type}' but code support for it is not there.") } } /** * Promote passed in range's low and high values to the largest * data type of their 'kinds' (e.g., byte to long, float to double). * @param range Range to be promoted * @return Range with the low and high values promoted and in proper order (low < high) */ private Range promoteRange(Range range) { final Comparable low = promoteValue(valueType, range.low) final Comparable high = promoteValue(valueType, range.high) if (low > high) { range.low = high range.high = low } else { range.low = low range.high = high } return range } /** * Convert passed in value to a similar value of the highest type. If the * valueType is not the same basic type as the value passed in, intelligent * conversions will happen, and the result will be of the requested type. * * An intelligent conversion example - String to date, it will parse the String * attempting to convert it to a date. Or a String to a long, it will try to * parse the String as a long. Long to String, it will .toString() the long, * and so on. * @return promoted value, or the same value if no promotion occurs. */ static Comparable promoteValue(AxisValueType srcValueType, Comparable value) { if (AxisValueType.STRING.is(srcValueType) || AxisValueType.CISTRING.is(srcValueType)) { return convertToString(value) } else if (AxisValueType.LONG.is(srcValueType)) { return convertToLong(value) } else if (AxisValueType.BIG_DECIMAL.is(srcValueType)) { return convertToBigDecimal(value) } else if (AxisValueType.DATE.is(srcValueType)) { return convertToDate(value) } else if (AxisValueType.DOUBLE.is(srcValueType)) { return convertToDouble(value) } else if (AxisValueType.EXPRESSION.is(srcValueType)) { return value } else if (AxisValueType.COMPARABLE.is(srcValueType)) { if (value instanceof String) { Matcher m = Regexes.valid2Doubles.matcher((String) value) if (m.matches()) { // No way to determine if it was supposed to be a Point2D. Specify as JSON for Point2D return new LatLon(convertToDouble(m.group(1)), convertToDouble(m.group(2))) } m = Regexes.valid3Doubles.matcher((String) value) if (m.matches()) { return new Point3D(convertToDouble(m.group(1)), convertToDouble(m.group(2)), convertToDouble(m.group(3))) } try { // Try as JSON return (Comparable) JsonReader.jsonToJava((String) value) } catch (Exception ignored) { return value } } return value } throw new IllegalArgumentException("AxisValueType '${srcValueType}' added but no code to support it.") } /** * @return boolean true if this Axis has a default column, false otherwise. */ boolean hasDefaultColumn() { return defaultCol != null } /** * @return Column (the default Column instance whose column.value is null) or null if there is no default column. */ Column getDefaultColumn() { return defaultCol } /** * @param value to test against this Axis * @return boolean true if the value will be found along the axis, false * if the value does not match anything along the axis. */ boolean contains(Comparable value) { try { return findColumn(value) != null } catch (Exception ignored) { return false } } /** * Fetch Columns on a rule axis where the passed in name is the first rule desired. All columns * until the ends will also be selected. * @param ruleName String name of rule to locate. * @return List of Columns starting with the column whose name matches 'ruleName' */ protected List getRuleColumnsStartingAt(String ruleName) { if (isEmpty(ruleName)) { // Since no rule name specified, all rule columns are returned to have their conditions evaluated. return columns } List cols = [] Column firstRule = findColumn(ruleName) if (firstRule == null) { // A name was specified for a rule, but did not match any rule names and there is no default column. throw new CoordinateNotFoundException("Rule named '${ruleName}' matches no column names on the rule axis '${name}', and there is no default column.", null, null, name, ruleName) } else if (firstRule.default) { // Matched no names, but there is a default column cols.add(defaultCol) return cols } // tailMap() efficiently snags everything matching and later Map result = displayOrder.tailMap(firstRule.displayOrder) cols.addAll(result.values()) if (hasDefaultColumn()) { cols.add(defaultCol) } return cols } /** * Find all rule Columns which have meta-properties that match all of the passed in required meta-properties. * In order for a Column to be selected, all keys in the requiredProps Map must match (case-insensitively) keys * in the meta-properties map of the Column, and the values must match each other. If a value is the special * value Axis.DONT_CARE, then only the key must be present, no comparison is performed on the value. * @param requiredProps Map of String key / value pair criteria * @return A List that properly match the passed in requiredProps Map. If no Columns match, then if * there is a Default column, it will be the single Column in the returned List. The order of the Columns * returned in the list will be in the order listed when .columns() is called. A Column instance will be * included only one or zero times. */ List findColumns(Map requiredProps) { Iterator i = columns.iterator() List columns = [] while (i.hasNext()) { Column column = i.next() if (hasRequiredProps(requiredProps, column.metaProperties)) { columns.add(column) } } if (columns.empty && hasDefaultColumn()) { columns.add(defaultCol) } return columns } /** * Pass the Axis (this) to the passed in Closure. The closure is expected to take one argument, this * Axis, and return a List instances. The closure can do whatever filtering it wants in * order to generated the List of Columns. The list can contain a column more than once. The generated * List is the orchestration (or order) in which the columns will be selected and used. *
     * Example:
     * axis.findColumns { Axis axis -> List columns = []; ... ; return columns }
     * 
* @param Closure that takes a single Axis argument and returns a List of Column instances. * @param * @return A List selected by the Closure. If the closure returns no columns, then if * there is a Default column, it will be the single Column in the returned List. The order of * the Columns returned in the list will be in the order the closure returned them. A Column * instance can be included more than one time. */ List findColumns(Closure closure) { List columns = closure(this) as List if (columns.empty && hasDefaultColumn()) { columns.add(defaultCol) } return columns } /** * Find all Columns which have a name (meta-property name) in the passed in Collection. * The Columns are returned based on the order in the passed in Collection. A Column * could be returned more than once. * @param orchestration Collection of String names used to select Columns. * @return List that matches the passed in orchestration list. If no Columns match, then if * there is a Default column, it will be the single Column in the returned List. The same Column * can be returned more than once if it was listed more than once in the passed in Collection. The * order of the Columns returned in the list will match the orchestration Collection order. */ List findColumns(Collection orchestration) { List columns = [] Iterator i = orchestration.iterator() while (i.hasNext()) { String ruleName = i.next() Column column = findColumnByName(ruleName) // O(1) if (column) { columns.add(column) } } if (columns.empty && hasDefaultColumn()) { columns.add(defaultCol) } return columns } /** * @param requiredProps Map of required key-value pairs (Criteria) * @param metaProps Map of key-value meta properties * @return true if the meta-Props contains all of the keys from the requiredProps Map, and all of the * values from the corresponding entries match. If the requireProps has the special value Axis.DONT_CARE * associated to it, then only the key must be present. */ private static boolean hasRequiredProps(Map requiredProps, Map metaProps) { Iterator> i = requiredProps.entrySet().iterator() while (i.hasNext()) { Map.Entry entry = i.next() if (metaProps.containsKey(entry.key)) { Object value = metaProps.get(entry.key) if (DONT_CARE != entry.value) { if (value != entry.value) { return false } } } else { return false } } return true } /** * Get a Comparable value that can be used to locate a Column on this axis. The passed in column may be * from another Axis (as in merging an axis from another cube). This API will return the name or ID if * this Axis is a RULE axis, otherwise it will return getValueThatMatches() API. * @param column Column source * @return Comparable value that can be passed to the findColumn() or deleteColumn() APIs. */ protected Comparable getValueToLocateColumn(Column column) { if (AxisType.RULE.is(type)) { return hasContent(column.columnName) ? column.columnName : column.id } return column.valueThatMatches } /** * Locate the column (value) along an axis. * @param value Comparable - A value that can be checked against the axis * @return Column that 'matches' the passed in value, or null if no column * found. 'Matches' because matches depends on AxisType. */ Column findColumn(final Comparable value) { if (value == null) { // By returning defaultCol, this lets null match it if there is one, or null if there is none. return defaultCol } final Comparable promotedValue = promoteValue(valueType, value) if (AxisType.DISCRETE.is(type)) { Column colToFind = valueToColumnMap.get(promotedValue) return colToFind == null ? defaultCol : colToFind } else if (AxisType.RANGE.is(type) || AxisType.SET.is(type)) { // RANGE axis searched in O(Log n) time using a binary search Column column = rangeToCol.get(promotedValue) return column == null ? defaultCol : column } else if (AxisType.RULE.is(type)) { if (promotedValue instanceof Long) { return idToCol.get(promotedValue as Long) } else if (promotedValue instanceof String || promotedValue instanceof GString) { Column colToFind = colNameToCol.get(promotedValue as String) return colToFind == null ? defaultCol : colToFind } else { throw new IllegalArgumentException("A column on a rule axis can only be located by the 'name' attribute (String) or ID (long), axis: ${name}, value: ${promotedValue}") } } else if (AxisType.NEAREST.is(type)) { return findNearest(promotedValue) // O(Log n) } else { throw new IllegalArgumentException("Axis type '${type}' added but no code supporting it.") } } /** * Locate a column on an axis using the 'name' meta property. If the value passed in matches no names, then * null will be returned. * Note: This is a case-insensitive match. * @param colName String name of column to locate * @return Column instance with the given name, otherwise null. */ Column findColumnByName(String colName) { return colNameToCol.get(colName) } private Column findNearest(final Comparable promotedValue) { if (valueToColumnMap.isEmpty()) { return null } NavigableMap navMap = (NavigableMap) valueToCol if (navMap.size() == 1) { return navMap.firstEntry().value } if (promotedValue instanceof Number) { // Provide O(Log n) access when Number (any Number derivative) used on a NEAREST axis Map.Entry entry1 = navMap.floorEntry(promotedValue as Comparable) Map.Entry entry2 = navMap.higherEntry(promotedValue as Comparable) if (entry1 == null) { return entry2.value } if (entry2 == null || entry1.key == entry2.key) { return entry1.value } Number low = entry1.key as Number Number high = entry2.key as Number Number value = promotedValue as Number Number delta1 = value - low Number delta2 = value - high if (delta1.abs() <= delta2.abs()) { return entry1.value } return entry2.value } else if (promotedValue instanceof Date) { // Provide O(Log n) access when Date (any Date derivative) used on a NEAREST axis Map.Entry entry1 = navMap.floorEntry(promotedValue as Comparable) Map.Entry entry2 = navMap.higherEntry(promotedValue as Comparable) if (entry1 == null) { return entry2.value } if (entry2 == null || entry1.key == entry2.key) { return entry1.value } Date low = entry1.key as Date Date high = entry2.key as Date Date value = promotedValue as Date long delta1 = abs(value.time - low.time) long delta2 = abs(value.time - high.time) if (delta1 <= delta2) { return entry1.value } return entry2.value } else { // Handle String, Point2D, Point3D, LatLon, etc. anything that implements the Distance interface double min = Double.MAX_VALUE Column saveCol = null for (Column column : columnsWithoutDefault) { double d = Proximity.distance(promotedValue, column.value) if (d < min) { // Record column that set's new minimum record min = d saveCol = column } } return saveCol } } /** * Convert the passed in Comparable to a Gauva Range * @param value Comparable, typically a DISCRETE or Range value * @return Guava Range instance - Range will be closedOpen [ ) for RANGE, and closed [ ] for DISCRETE value */ private static com.google.common.collect.Range valueToRange(Comparable value) { if (value instanceof Range) { Range range = (Range) value return com.google.common.collect.Range.closedOpen(range.low, range.high) } else { return com.google.common.collect.Range.closed(value, value) } } /** * Ensure that the passed in range does not overlap an existing Range on this * 'Range-type' axis. Test low range limit to see if it is valid. * Axis is already a RANGE type before this method is called. * @param value Range (value) that is intended to be a new low range limit. * @return true if the Range overlaps this axis, false otherwise. */ private boolean doesOverlap(Range range) { RangeMap ranges = rangeToCol.subRangeMap(valueToRange(range)) return ranges.asMapOfRanges().size() > 0 } /** * Test RangeSet to see if it overlaps any of the existing columns on * this cube. Axis is already a RangeSet type before this method is called. * @param value RangeSet (value) to be checked * @return true if the RangeSet overlaps this axis, false otherwise. */ private boolean doesOverlap(RangeSet set) { Iterator i = set.iterator() while (i.hasNext()) { Comparable item = i.next() RangeMap rangeMap = rangeToCol.subRangeMap(valueToRange(item)) if (rangeMap.asMapOfRanges().size() > 0) { return true } } return false } /** * @return List representing all of the Columns on this Axis. This is a copy, so operations * on the List will not affect the Axis columns. However, the Column instances inside the List are * not 'deep copied' so modifications to them should not be made, as it would violate the internal * Map structures maintaining the column indexing. The Columms are in SORTED or DISPLAY order * depending on the 'preferredOrder' setting. */ List getColumns() { // return 'view' of Columns that matches the desired order (sorted or display) List cols = columnsWithoutDefault if (defaultCol != null) { // Add in optional Default Column cols.add(defaultCol) } return cols } /** * @return List that contains all Columns on this axis (excluding the Default Column if it exists). The * Columns will be returned in sorted order. It is a copy of the internal list, therefore operations on the * returned List are safe, however, no changes should be made to the contained Column instances, as it would * violate internal indexing structures of the Axis. */ List getColumnsWithoutDefault() { if (AxisType.DISCRETE.is(type)) { Collection col = preferredOrder == DISPLAY ? displayOrder.values() : valueToColumnMap.values() return new ArrayList<>(col) // // Note: If we decide that findColumn should be O(1) not O(logN), then we can add the code below. // // Note: However, this is slower than returning the values() side of the map (about 3x times slower). // if (AxisValueType.CISTRING.is(valueType)) // { // List cols = new ArrayList<>(valueToColumnMap.values()) // // Note: If we decide that findColumn should be O(1) not O(logN), then we can add this // cols.sort(new Comparator() { // int compare(Column c1, Column c2) // { // String v1 = c1.value // String v2 = c2.value // return v1.compareToIgnoreCase(v2) // } // }) // return cols // } // else // { // List cols = new ArrayList<>(valueToColumnMap.values()) // cols.sort(new Comparator() { // int compare(Column c1, Column c2) // { // return c1.value <=> c2.value // } // }) // return cols // } } else if (AxisType.NEAREST.is(type)) { return new ArrayList<>((preferredOrder == SORTED) ? valueToColumnMap.values() : displayOrder.values()) } else if (AxisType.RULE.is(type)) { return new ArrayList<>(displayOrder.values()) } else if (AxisType.RANGE.is(type) || AxisType.SET.is(type)) { List cols = new ArrayList<>(size()) if (preferredOrder == SORTED) { // Consolidate Columns on the value side of the Map (multiple ranges can point to the same Column) Set set = new LinkedHashSet<>() // maintain order set.addAll(rangeToCol.asMapOfRanges().values()) cols.addAll(set) } else { cols.addAll(displayOrder.values()) } return cols } throw new IllegalStateException("AxisValueType '${type}' added but no code to support it.") } /** * @return true if all the properties on the passed in object are the same as this Axis. If the passed in * object is not an Axis, false is returned. */ boolean areAxisPropsEqual(Object o) { if (this.is(o)) { return true } if (!(o instanceof Axis)) { return false } Axis axis = (Axis) o if (preferredOrder != axis.preferredOrder) { return false } if (name != axis.name) { return false } if (type != axis.type) { return false } if (valueType != axis.valueType) { return false } return fireAll == axis.fireAll } /** * Return a display-friendly String for the passed in column. */ protected static String getDisplayColumnName(Column column) { String colName = hasContent(column.columnName) ? column.columnName : column.value return colName ?: 'default' } /** * Find the Column on 'this' axis which has the same ID as the passed in axis. If not found, * then check for Column with the same value (or name in case of RULE axis). * @param source * @return */ protected Column locateDeltaColumn(Column source) { Column column = getColumnById(source.id) if (column != null) { return column } if (AxisType.RULE.is(type)) { return findColumn(source.columnName) } else { return findColumn(source.value) } } /** * This method is only for testing purposes * @return the size of the rangeToCol Map */ protected int getRangeToColSize() { return rangeToCol.asMapOfRanges().size() } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy