/* * This software is distributed under the terms of the FSF * Gnu Lesser General Public License (see lgpl.txt). * * This program is distributed WITHOUT ANY WARRANTY. See the * GNU General Public License for more details. */ package com.scooterframework.orm.activerecord; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import com.scooterframework.admin.EnvConfig; import com.scooterframework.common.util.Converters; import com.scooterframework.common.util.StringUtil; import com.scooterframework.common.util.WordUtil; import com.scooterframework.orm.sqldataexpress.config.DatabaseConfig; import com.scooterframework.orm.sqldataexpress.object.RowInfo; /** * RelationManager class manages relations. * * @author (Fei) John Chen */ public class RelationManager { private static final RelationManager me = new RelationManager(); private RelationManager() { } public static RelationManager getInstance() { return me; } /** * Loads relations for a class type. * * @param recordClass class type */ public void registerRelations(Class<? extends ActiveRecord> recordClass) { _registerRelations(recordClass); } /** * Sets up relation with other model(s). <tt>target</tt> parameter can be * either the model name of the target or a descriptive string of the * target. In the latter case, either the properties must contain key * <tt>model</tt> to indicate the model name of the target or the * <tt>targetClass</tt> input parameter is not null. * * In a property string, each name-value pair is separated by ';' * character, while within each name-value pair, name and value strings * are separated by ':' character. * * For example, a property string like the following * <blockquote><pre> * conditions_sql: id in (1, 2, 3); include: category, user; * order_by: first_name, salary desc; cascade: delete * </pre></blockquote> * * will be converted to a HashMap with the following entries: * <blockquote><pre> * key => value * conditions_sql => id in (1, 2, 3) * include => category, user * order_by => first_name, salary desc * cascade => delete * </pre></blockquote> * * @param ownerClass owner class * @param type type of relation * @param associationId association name * @param targetClass target class * @param properties string of properties */ public void setupRelation(Class<? extends ActiveRecord> ownerClass, String type, String associationId, Class<? extends ActiveRecord> targetClass, String properties) { if (ownerClass == null) throw new IllegalArgumentException("Error in setupRelation: ownerClass is not specified."); if (type == null) throw new IllegalArgumentException("Error in setupRelation: type is not specified."); if (associationId == null && targetClass == null) throw new IllegalArgumentException("Error in setupRelation: either associationId or targetClass must be specified."); String targetModel = null; if (associationId == null) { targetModel = ActiveRecordUtil.getModelName(targetClass); associationId = (Relation.HAS_MANY_TYPE.equals(type))?WordUtil.pluralize(targetModel):targetModel; } String key = getRelationKey(ownerClass, associationId); if (relations.containsKey(key)) return; Map<String, String> pmap = Converters.convertSqlOptionStringToMap(properties); if (targetModel == null) { if (pmap != null) { targetModel = pmap.get(ActiveRecordConstants.key_model); } if (targetModel == null) { if (targetClass != null) { targetModel = ActiveRecordUtil.getModelName(targetClass); } else { targetModel = (Relation.HAS_MANY_TYPE.equals(type))?WordUtil.singularize(associationId):associationId; } } } if (targetClass == null) { targetClass = ActiveRecordUtil.getHomeInstance(EnvConfig.getInstance().getModelClassName(targetModel)).getClass(); } String mapping = null; if (pmap != null) { mapping = pmap.get(ActiveRecordConstants.key_mapping); } if (mapping == null) { mapping = getDefaultMapping(ownerClass, type, associationId, targetClass); } Relation r = createRelation(ownerClass, type, associationId, targetModel); if (pmap != null) { validateCascade(pmap, type, key); r.setProperties(pmap); } r.setMapping(mapping); r.setTargetClass(targetClass); r.setRelationKey(key); cacheRelation(key, r); //register target class _registerRelations(targetClass); } /** * Sets up has-many-through relation. * * @param ownerClass relation owner class * @param targets target association name * @param throughAssociation the through association name * @param properties string of properties * @param joinInputs map of input key/value pairs for the join model */ public void setupHasManyThroughRelation(Class<? extends ActiveRecord> ownerClass, String targets, String throughAssociation, String properties, Map<String, Object> joinInputs) { if (ownerClass == null) throw new IllegalArgumentException("Error in setupHasManyThroughRelation: ownerClass is not specified."); if (targets == null) throw new IllegalArgumentException("Error in setupHasManyThroughRelation: targets association is not specified."); if (throughAssociation == null) throw new IllegalArgumentException("Error in setupHasManyThroughRelation: through association is not specified."); String key = getRelationKey(ownerClass, targets); if (relations.containsKey(key)) return; String acKey = getRelationKey(ownerClass, throughAssociation); Relation acRelation = (Relation)relations.get(acKey); if (acRelation == null) throw new IllegalArgumentException("Error in setupHasManyThroughRelation: " + throughAssociation + " association must be specified in class " + ownerClass + "."); Map<String, String> pmap = Converters.convertSqlOptionStringToMap(properties); Class<? extends ActiveRecord> middleC = acRelation.getTargetClass(); _registerRelations(middleC); String source = pmap.get(ActiveRecordConstants.key_source); Relation cbRelation = null; if (source == null) { String cbKey = getRelationKey(middleC, targets); cbRelation = (Relation)relations.get(cbKey); if (cbRelation == null) { String target = WordUtil.singularize(targets); cbKey = getRelationKey(middleC, target); cbRelation = (Relation)relations.get(cbKey); if (cbRelation == null) { throw new IllegalArgumentException("Error in setupHasManyThroughRelation: " + targets + " or " + target + " association must be specified in class " + middleC + "."); } } } else { String cbKey = getRelationKey(middleC, source); cbRelation = (Relation)relations.get(cbKey); if (cbRelation == null) { throw new IllegalArgumentException("Error in setupHasManyThroughRelation: " + source + " association must be specified in class " + middleC + "."); } } HasManyThroughRelation r = new HasManyThroughRelation(ownerClass, targets, throughAssociation, acRelation, cbRelation); if (pmap != null) r.setProperties(pmap); r.setJoinInputs(joinInputs); r.setRelationKey(key); cacheRelation(key, r); } /** * Creates a RecordRelation between owner (record instance) and its * associated model. * * @param record ActiveRecord instance of the owner * @param associationId association name for the target * @return RecordRelation a specific RecordRelation */ public RecordRelation createRecordRelation(ActiveRecord record, String associationId) { if (record == null || associationId == null) return null; Relation relation = findOrRegisterRelation(record, associationId); if (relation == null) throw new UndefinedRelationException( ActiveRecordUtil.getModelName(record.getClass()), associationId); return createRecordRelation(record, relation); } /** * Return a list of name type combination for a model class. * * An item in the list is like: * order:invoice = has-one * order:item = has-many * * @param clz ActiveRecord class type * @return List */ public List<String> getAllRelationNameTypes(Class<? extends ActiveRecord> clz) { _registerRelations(clz); String model = ActiveRecordUtil.getModelName(clz); String relationOwnerKey = getRelationOwnerKey(model); List<String> nameTypes = new ArrayList<String>(); for (Map.Entry<String, Relation> entry : relations.entrySet()) { String key = entry.getKey(); if (key == null) continue; if (key.startsWith(relationOwnerKey)) { Relation r = entry.getValue(); String rType = (r != null)?r.getRelationType():null; nameTypes.add(key + " = " + rType); } } return nameTypes; } /** * Returns a list of relation instances owned by an owner class type. * * @param owner ActiveRecord class type * @return List of relation instances */ public List<Relation> getOwnedRelations(Class<? extends ActiveRecord> owner) { _registerRelations(owner); String model = ActiveRecordUtil.getModelName(owner); String relationOwnerKey = getRelationOwnerKey(model); List<Relation> rls = new ArrayList<Relation>(); for (Map.Entry<String, Relation> entry : relations.entrySet()) { String key = entry.getKey(); if (key != null && key.startsWith(relationOwnerKey)) { rls.add(entry.getValue()); } } return rls; } /** * Returns a list of relation instances owned by an owner class type * with the specific target class type. * * @param owner owner ActiveRecord class type * @param target target ActiveRecord class type * @return List of relation instances */ public List<Relation> getRelations(Class<? extends ActiveRecord> owner, Class<? extends ActiveRecord> target) { _registerRelations(owner); String model = ActiveRecordUtil.getModelName(owner); String relationOwnerKey = getRelationOwnerKey(model); List<Relation> rls = new ArrayList<Relation>(); for (Map.Entry<String, Relation> entry : relations.entrySet()) { String key = entry.getKey(); if (key != null && key.startsWith(relationOwnerKey)) { Relation r = (Relation)relations.get(key); if (target.getName().equals(r.getTargetClass().getName())) { rls.add(r); } } } return rls; } /** * Removes all cached relations owned by a model. * * @param model the owner of the relation */ public void removeRelationsFor(String model) { String relationOwnerKey = getRelationOwnerKey(model); for (Map.Entry<String, Relation> entry : relations.entrySet()) { String key = entry.getKey(); if (key != null && key.startsWith(relationOwnerKey)) { relations.remove(key); } } } /** * Returns relation between owner and target. * * @param owner relation owner class * @param associationId association id for target model in lower case * @return relation */ public Relation getRelation(Class<? extends ActiveRecord> owner, String associationId) { _registerRelations(owner); return (Relation)relations.get(getRelationKey(owner, associationId)); } /** * Returns relation type from owner class to target class. * * @param owner class type for relation owner * @param target class type for relation target * @return relation type */ public String getRelationType(Class<? extends ActiveRecord> owner, Class<? extends ActiveRecord> target) { List<Relation> list = getRelations(owner, target); if (list == null) return null; String type = null; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = it.next(); if (r != null) { type = r.getRelationType(); break; } } return type; } /** * Checks if there is a belongs-to relation between the two class types. * * @param endA class type for owner class * @param endB class type for target class * @return true if endA class belongs-to endB class */ public boolean existsBelongsToRelationBetween(Class<? extends ActiveRecord> endA, Class<? extends ActiveRecord> endB) { List<Relation> list = getRelations(endA, endB); if (list == null) return false; boolean status = false; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = (Relation)it.next(); if (Relation.BELONGS_TO_TYPE.equals(r.getRelationType())) { status = true; break; } } return status; } /** * Checks if there is a has-one relation between the two class types. * * @param endA class type for owner class * @param endB class type for target class * @return true if endA class has-one endB class */ public boolean existsHasOneRelationBetween(Class<? extends ActiveRecord> endA, Class<? extends ActiveRecord> endB) { List<Relation> list = getRelations(endA, endB); if (list == null) return false; boolean status = false; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = (Relation)it.next(); if (r != null && Relation.HAS_ONE_TYPE.equals(r.getRelationType())) { status = true; break; } } return status; } /** * Checks if there is a has-many relation between the two class types. * * @param endA class type for owner class * @param endB class type for target class * @return true if endA class has-many endB class */ public boolean existsHasManyRelationBetween(Class<? extends ActiveRecord> endA, Class<? extends ActiveRecord> endB) { List<Relation> list = getRelations(endA, endB); if (list == null) return false; boolean status = false; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = (Relation)it.next(); if (r != null && Relation.HAS_MANY_TYPE.equals(r.getRelationType())) { status = true; break; } } return status; } /** * Checks if there is a has-many-through relation between the two class types. * * @param endA class type for owner class * @param endB class type for target class * @return true if endA class has-many-through endB class */ public boolean existsHasManyThroughRelationBetween(Class<? extends ActiveRecord> endA, Class<? extends ActiveRecord> endB) { List<Relation> list = getRelations(endA, endB); if (list == null) return false; boolean status = false; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = (Relation)it.next(); if (r != null && Relation.HAS_MANY_THROUGH_TYPE.equals(r.getRelationType())) { status = true; break; } } return status; } /** * Returns a belongs-to relation which exists between the two class types. * * @param endA class type for owner class * @param endB class type for target class * @return a belongs-to relation */ public Relation getBelongsToRelationBetween(Class<? extends ActiveRecord> endA, Class<? extends ActiveRecord> endB) { Relation rel = null; List<Relation> list = getRelations(endA, endB); if (list == null) return null; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = (Relation)it.next(); if (r != null && Relation.BELONGS_TO_TYPE.equals(r.getRelationType())) { rel = r; break; } } return rel; } /** * Returns a has-one relation which exists between the two class types. * * @param endA class type for owner class * @param endB class type for target class * @return a has-one relation */ public Relation getHasOneRelationBetween(Class<? extends ActiveRecord> endA, Class<? extends ActiveRecord> endB) { Relation rel = null; List<Relation> list = getRelations(endA, endB); if (list == null) return null; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = (Relation)it.next(); if (r != null && Relation.HAS_ONE_TYPE.equals(r.getRelationType())) { rel = r; break; } } return rel; } /** * Returns a has-many relation which exists between the two class types. * * @param endA class type for owner class * @param endB class type for target class * @return a has-many relation */ public Relation getHasManyRelationBetween(Class<? extends ActiveRecord> endA, Class<? extends ActiveRecord> endB) { Relation rel = null; List<Relation> list = getRelations(endA, endB); if (list == null) return null; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = (Relation)it.next(); if (r != null && Relation.HAS_MANY_TYPE.equals(r.getRelationType())) { rel = r; break; } } return rel; } /** * Returns a has-many-through relation which exists between the two class types. * * @param endA class type for owner class * @param endB class type for target class * @return a has-many-through relation */ public Relation getHasManyThroughRelationBetween(Class<? extends ActiveRecord> endA, Class<? extends ActiveRecord> endB) { Relation rel = null; List<Relation> list = getRelations(endA, endB); if (list == null) return null; Iterator<Relation> it = list.iterator(); while(it.hasNext()) { Relation r = (Relation)it.next(); if (r != null && Relation.HAS_MANY_THROUGH_TYPE.equals(r.getRelationType())) { rel = r; break; } } return rel; } /** * Registers a Category. * * @param center center class of the category * @param category category name * @param idField id field name for the category * @param typeField type field name for the category */ public void registerCategory(Class<? extends ActiveRecord> center, String category, String idField, String typeField) { Category cat = (Category)categoryMap.get(category); if (cat == null) { cat = new Category(center, category, idField, typeField); categoryMap.put(category, cat); } } /** * Gets a category list that all have the same center class. * * @param center center class of the category * @return a category list */ public List<Category> getRegisteredCategory(Class<? extends ActiveRecord> center) { String centerClassName = center.getName(); List<Category> categories = new ArrayList<Category>(); for (Map.Entry<String, Category> entry : categoryMap.entrySet()) { Category category = entry.getValue(); if (category != null && centerClassName.equals(category.getCenterClass().getName())) { categories.add(category); } } return categories; } /** * Returns a declared Category instance. If the category is not declared, * null will be returned. * * @param category category name * @return category a Category instance if there is one */ public Category getCategory(String category) { return (Category)categoryMap.get(category); } /** * Returns model name of a type in a category. * * @param category category name * @param type type name of the model * @return model model name for the type */ public String getCategoryEntity(String category, String type) { Category cat = getCategory(category); if (cat == null) return null; return cat.getEntityByType(type); } /** * returns default FK mapping. * * The default FK mapping follows these rules: * * For belongsTo relation, class A is the owner of the relation and * class A holds the foreign key FK: * <pre> * 1. If id is not class B's primary key, the mapping is * <tt>{Class B's primary key}={Class B's primary key}</tt>. For * example, if class B's PK is order_id, then the mapping is * order_id=order_id. Here we assume that <tt>order_id</tt> is a * foreign key column of Class A. * 2. If id is class B's primary key, the mapping is * {Class B's model name in lower case}_id=id * * Example: Lines belongsTo Order. The default mapping is "order_id=id". * </pre> * * For hasMany or hasOne relation, class A is the owner of the relation and * class B holds the FK: * <pre> * 1. If id is not class A's primary key, the mapping is * <tt>{Class A's primary key}={Class A's primary key}</tt>. For * example, if class A's PK is order_id, then the mapping is * order_id=order_id. Here we assume that <tt>order_id</tt> is a * foreign key column of Class B. * 2. If id is class A's primary key, the mapping is * id={Class A's model name in lower case}_id * * Example: Order hasMany Lines. The default mapping is "id=order_id". * </pre> * * @param a The end a class * @param type String of type of relation * @param target target name of the associated class. * @param b The end b class */ public String getDefaultMapping(Class<? extends ActiveRecord> a, String type, String target, Class<? extends ActiveRecord> b) { String mapping = ""; // In belongs-to relation, class A holds FK and is also the owner. if (Relation.BELONGS_TO_TYPE.equalsIgnoreCase(type)) { ActiveRecord targetHome = (ActiveRecord)ActiveRecordUtil.getHomeInstance(b); RowInfo ri = targetHome.getRowInfo(); if ( ri == null) { throw new RelationException("The RowInfo for class " + b.getName() + " cannot be null."); } String[] pkNames = ri.getPrimaryKeyColumnNames(); if (StringUtil.isStringInArray("ID", pkNames, true)) { String fk = target + "_id"; verifyExistenceOfColumn(a, fk); mapping = fk + "=id"; } else { int size = pkNames.length; for (int i=0; i<size-1; i++) { String fk = pkNames[i]; verifyExistenceOfColumn(a, fk); mapping += fk + "=" + fk + ","; } String fk = pkNames[size-1]; verifyExistenceOfColumn(a, fk); mapping += fk + "=" + fk; } } else // In has-one and has-many relation, class B holds FK. if (Relation.HAS_ONE_TYPE.equalsIgnoreCase(type) || Relation.HAS_MANY_TYPE.equalsIgnoreCase(type)) { ActiveRecord ownerHome = (ActiveRecord)ActiveRecordUtil.getHomeInstance(a); RowInfo ri = ownerHome.getRowInfo(); if ( ri == null) { throw new RelationException("The RowInfo for class " + a.getName() + " cannot be null."); } String[] pkNames = ri.getPrimaryKeyColumnNames(); if (StringUtil.isStringInArray("ID", pkNames, true)) { String fk = ActiveRecordUtil.getModelName(a) + "_id"; verifyExistenceOfColumn(b, fk); mapping = "id=" + fk; } else { int size = pkNames.length; for (int i=0; i<size-1; i++) { String fk = pkNames[i]; verifyExistenceOfColumn(b, fk); mapping += fk + "=" + fk + ","; } String fk = pkNames[size-1]; verifyExistenceOfColumn(b, fk); mapping += fk + "=" + fk; } } return mapping; } private void verifyExistenceOfColumn(Class<? extends ActiveRecord> clz, String columnName) { try { ActiveRecordUtil.verifyExistenceOfColumn(clz, columnName); } catch(Exception ex) { throw new RelationException("Failed to create default relation " + "mapping because " + ex.getMessage() + ". You might have to " + "specify mapping explicitly."); } } private Relation createRelation(Class<? extends ActiveRecord> a, String type, String associationId, String targetModel) { Relation r = null; if (Relation.BELONGS_TO_TYPE.equalsIgnoreCase(type)) { r = new BelongsToRelation(a, associationId, targetModel); } else if (Relation.HAS_ONE_TYPE.equalsIgnoreCase(type)) { r = new HasOneRelation(a, associationId, targetModel); } else if (Relation.HAS_MANY_TYPE.equalsIgnoreCase(type)) { r = new HasManyRelation(a, associationId, targetModel); } else { throw new UnsupportedRelationTypeException(type); } return r; } private Relation findOrRegisterRelation(ActiveRecord record, String associationId) { String key = getRelationKey(record.getClass(), associationId); Relation r = (Relation)relations.get(key); //register relation if (r == null) { registerRelations(record.getClass()); r = (Relation)relations.get(key); } return r; } /** * Check if a class has been set up relations. */ private boolean hasCompletedRelationSetup(String className) { return completedClasses.contains(className); } private void completeRegistration(String className) { if (DatabaseConfig.getInstance().isInDevelopmentEnvironment()) return; completedClasses.add(className); } private void _registerRelations(Class<? extends ActiveRecord> clz) { String fullClassName = clz.getName(); if (!hasCompletedRelationSetup(fullClassName)) { ActiveRecord home = ActiveRecordUtil.getHomeInstance(fullClassName); home.registerRelations(); completeRegistration(fullClassName); } } private RecordRelation createRecordRelation(ActiveRecord record, Relation relation) { RecordRelation rr = null; String type = relation.getRelationType(); if (Relation.BELONGS_TO_TYPE.equalsIgnoreCase(type)) { rr = new BelongsToRecordRelation(record, (BelongsToRelation)relation); } else if (Relation.HAS_ONE_TYPE.equalsIgnoreCase(type)) { rr = new HasOneRecordRelation(record, (HasOneRelation)relation); } else if (Relation.HAS_MANY_TYPE.equalsIgnoreCase(type)) { rr = new HasManyRecordRelation(record, (HasManyRelation)relation); } else if (Relation.HAS_MANY_THROUGH_TYPE.equalsIgnoreCase(type)) { rr = new HasManyThroughRecordRelation(record, (HasManyThroughRelation)relation); } else { throw new UnsupportedRelationTypeException(type); } return rr; } /** * Returns formated relation key. * @param owner relation owner class * @param associationId association id for target model in lower case * @return relation key */ private String getRelationKey(Class<? extends ActiveRecord> owner, String associationId) { return getRelationKey(ActiveRecordUtil.getModelName(owner), associationId); } /** * Returns formated relation key. The relation key uses the following format:<br/> * <tt>{owner}:{associationId}</tt> where <tt>{owner}</tt> is model name of owner * class and <tt>{associationId}</tt> is association id for target class. * * <pre> * Examples: * Class A Relationship Class B Relation Key * ------- ------------ ------- ------------- * item belongs-to order item:order * order has-many item order:items * </pre> * * @param owner model name for owner class in lower case * @param associationId association id for target model in lower case * @return relation key */ private String getRelationKey(String owner, String associationId) { return getRelationOwnerKey(owner) + associationId.toLowerCase(); } private static String getRelationOwnerKey(String a) { return (a + ":").toLowerCase(); } private void validateCascade(Map<String, String> properties, String rtype, String relationKey) { if (properties == null) return; String cascade = properties.get(ActiveRecordConstants.key_cascade); cascade = (cascade == null)?Relation.CASCADE_NONE:cascade; if (Relation.BELONGS_TO_TYPE.equals(rtype) && !Relation.CASCADE_NONE.equals(cascade)) { throw new IllegalArgumentException("The cascade is not allowed for " + rtype + " type in relation " + relationKey + "."); } if (!Relation.CASCADE_NONE.equals(cascade) && !Relation.CASCADE_DELETE.equals(cascade) && !Relation.CASCADE_SIMPLY_DELETE.equals(cascade) && !Relation.CASCADE_NULLIFY.equals(cascade) ) { throw new IllegalArgumentException("The cascade attribute is not supported: [" + cascade + "] in relation " + relationKey + "."); } } private void cacheRelation(String key, Relation relation) { relations.put(key, relation); } /** * Map of relations * * The key in the map is a combination of class a name and class b name. * Value is a relation object. * * See {@link #getRelationKey(String a, String b)} method. */ private Map<String, Relation> relations = new ConcurrentHashMap<String, Relation>(); //List of setup classes. Each entry in the list is a full class name. private List<String> completedClasses = new ArrayList<String>(); /** * Map of category name and corresponding category instance, key is * category name and value is the Category instance. */ private Map<String, Category> categoryMap = new ConcurrentHashMap<String, Category>(); }