The first thing to ask yourself is "am I ever going to have a record hierarchy that has more than 100 levels?" If the answer is no, then it doesn't make much sense to spend time trying to work out an alternative approach. If this code is called from a trigger context though, effort spent in reducing queries is pretty easy to justify.
Assuming that you do have a valid reason to seek an alternative approach...
I ran into a similar problem when developing an IPAM (IP Address Management) solution for my org.
Given a subnet of IP addresses like 10.0.32.128/25 (the CIDR (Classless Inter-Domain Routing) representation for the range of addresses 10.0.32.128 - 10.0.32.255), what is the largest subnet that it belongs to? (10.0.0.0/8)
Each subnet had 1 record, with the prefix length decreasing by 1 for each level of parent. The parent record for 10.0.32.128/25 would be 10.0.32.0/24. The grandparent would be 10.0.32.0/23, and so on.
Instead of querying in a loop to get to the top-level record, the approach I took was to store the top-level record id in every record. That approach works well for me since my top-level records are effectively set in stone. It still leaves us with the problem of propagating that top-level record Id down to the children though.
My solution to that problem was to query all of my records (with the parent relationship field) and then build the object hierarchy in memory. Something like
// We can use a Map to help us recursively traverse a record hierarchy
// This is a lot more friendly on Heap space than storing entire records in a wrapper class
Map<Id, List<Id>> parentIdToChildrenIds = new Map<Id, List<Id>>();
// We should also keep track of which Ids are our top-level Ids (gotta start the
// recursion we'll get to later at the top)
List<Id> topLevelRecordIds = new List<Id>();
// Build the hierarchy in our map (be mindful of query selectivity and the 50k row limit)
for(IP_Address__c ipAddr :[SELECT Id, Parent_IP_Block__c FROM IP_Address__c]){
if(!parentIdToChildrenIds.containsKey(ipAddr.Parent_IP_Block__c)){
parentIdToChildrenIds.put(ipAddr.Parent_IP_Block__c, new List<Id>());
}
parentIdToChildrenIds.get(ipAddr.Parent_IP_Block__c).add(ipAddr.Id);
// If there's no parent, that makes it a top-level record
if(String.isBlank(ipAddr.Parent_IP_Block__c)){
topLevelRecordIds.add(ipAddr.Id);
}
}
// For ease and my comfort, I'm writing this as a recursive helper method that would
// live in some class.
public Map<Id, IP_Address__c> propagateToChildren(Id topLevelId, Id currentRecordId, Map<Id, List<Id>> parentIdToChildrenIds){
// We always want to add the current Id to the list of things we return
Map<Id, IP_Address__c> ipAddrToUpdate = new Map<Id, IP_Address__c>{
currentRecordId => new IP_Address__c(Id = currentRecordId, Top_Level_IP_Block__c = topLevelId)
};
// Base case: current record has no children
// Just return what we've created so far
if(!parentIdToChildrenIds.containsKey(currentRecordId)){
return ipAddrToUpdate;
}
// Recursive case: current record has children
// Recursively call the method, and add the results to our current results
for(Id childId :parentIdToChildrenIds.get(currentRecordId)){
ipAddrToUpdate.putAll(propagateToChildren(topLevelId, childId, parentIdToChildrenIds);
}
return ipAddrToUpdate;
}
This approach also needs to keep the governor limit on DML rows in mind. If you have a large enough dataset, it should be fairly easy to adapt this to be Batchable or Queueable.