Arlodotexe / OwlCore.Storage

The most flexible file system abstraction, ever. Built in partnership with the UWP Community.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Remove Path and rename IAddressable*

Arlodotexe opened this issue · comments

commented

On Discord in the UWP Community, d2dyno#5600 has suggested to rename IAddressable to ILocatable.

This issue serves as discussion and planning for the suggestion.


Update: Rather than a simple rename, we've effectively scrapped the interface and tried again with new hindsight. Tl;dr -> #18 (comment)

commented

This is a breaking change that would affect both implementors and consumers, in a lot of places.
We'll have to rename related interfaces IAddressableFolder, IAddressableFile, etc., and some implementations, like AddressableMemoryFolder.

The interfaces are commonly used for type checks, so a lot of consumer code would need to be replaced as well. This would be a big breaking change, so if we decide to move forward with it, it will need to wait until we move to the Community Toolkit.

It really comes down to readability and familiarity. Majority of developers use the term "location" as a path to a resource.

Following on that, ILocatable.. is more descriptive by default, with IAddressable.. you have to check what the interface is exactly for. IAddressable.. could also be mistaken for an address on the network - i.e. the file/folder resides on a network with address of some kind.

commented

It may ultimately help the most to revisit the naming process for this, and just it try again. It'll give us all the info we need to make our decision.


What it's for

IAddressableStorable is described as "A file or folder that resides within a folder structure."

A file or folder can be created standalone, without existing within another folder. Examples of this are MemoryFolder, HttpFile, and IpfsFile. Since not everything has a parent, we leave this out of our default, minimum IFile and IFolder interfaces, and add that functionality using IAddressableStorable.

The interface IAddressableStorable does 2 things:

  • Provides the Path property
    • Tells you where you are in relation to the "root" folder.
    • Used by tooling in combination with GetItemsAsync and GetParentAsync to navigate a folder programmatically.
  • Provides the GetParentAsync method that returns the parent IFolder
    • Returns null if you're in the root.

What to call it

  • IFolder.GetItemsAsync() allows us to crawl down a folder structure
  • IAddressableStorage allows us to crawl back up that folder structure.

This in mind, neither Addressable nor Locatable seem like the best name we can come up with. 🤔

Personally I find IAddressable more descriptive. With that name, I'd assume it describes an item that can be addressed, or has a path. ILocatable feels less clear to me: what exactly does it mean for a folder to be locatable?

ILocatable means that a folder/file can be located in a folder structure, i.e. has a path. When you think about it more, those are the same things. It's really about readability and the points that I mentioned in first comment.

commented

Readability is too subjective to be our compass here. It's much more important that it's an accurate name, which is why I laid out what it is / what it's used for.

This in mind, neither Addressable nor Locatable seem like the best name we can come up with. 🤔

Any other suggestions?

commented

I think this will be a big help in deciding the best name for this:

IFolder.GetItemsAsync() allows us to crawl down a folder structure
IAddressableStorage allows us to crawl back up that folder structure.

commented

Okay, I took time to really think it through. We're thinking of:

  • Doing away with Path.
    • It doesn't serve any purpose from the contract side of things - the core interfaces only handle by Id, everything else is an interface / extension method or an implementation detail.
    • No tooling built with this set of interfaces actually needs this on every implementation. In Strix, I haven't been able to find a single usage of Path that couldn't be easily replaced by another property or method call.
    • The data you need should always be a type check away, and should be type checked. There's real differences between a SystemFolder.Path and an FtpFolder.Path, in ways we aren't responsible for standardizing or abstracting.
  • Renaming IAddressable* to IFolderChild.
    • Since GetParentAsync is the only thing on that interface now, we can rename it to something that reflects that. Open to suggestions, otherwise we'll just use IFolderChild since the wording plays well with GetParentAsync.
    • We'll keep the interface, because it should be optional for the implementor.
    • The return value still needs to be nullable, so that single implementation can represent both the root folder and a subfolder of the root.

Reviewing our options for implementing the feature "does it have a parent folder":

  • Using an interface and a non-nullable return value:
    • Compile-time checks like if (folder is IFolderChild) are all you need to guarantee getting an item with a parent.
    • Since "does it have a parent" must be available at compile time, it requires different implementations for the root folder (no parent) vs somewhere in the folder structure (has a parent). Awful and messy for both implementors and consumers.
  • Using a nullable return value:
    • Since "does it have a parent" is not tied exclusively to the interface, this adds a runtime value check to see if you're in the root.
    • Allows sharing a single implementation for both root and child items
    • If we don't use an interface to make it an optional feature, it would need to go on IStorable, forcing all implementors to implement it, even if they don't support it.

The solution is to use both. We treat our nullable return value as the "does it have a parent folder" feature, and we'll use the interface as the "make it optional" feature.

Basically, if the underlying file system can return a parent, you implement IFolderChild
Whether or not it actually does is a matter of what folder you're in (assuming no exceptions are thrown)


This creates a new problem, though - there are 2 ways to check if you have a root folder, and that could get confusing for consumers.

So we'll make it easier, using the trusty "extension method / fastpath interface" approach:

public interface IFastGetRoot
{
    public async Task<IFolder?> GetRootAsync();
}

public static class FolderExtensions
{
    public static async Task<IFolder?> GetRootAsync(this IFolderChild item)
    {
          if (item is IFastGetRoot fastRoot)
               return fastRoot.GetRootAsync();

          var parent = await item.GetParentAsync();

          // Item is the root already.
          if (parent is null || parent is not IFolderChild parentAsChild)
               return null;
          else
          // Item is not the root, try asking the parent.
              return await parentAsChild.GetRootAsync();
    }
}
commented

Getting rid of Path would leave a functionality gap that needs addressed. Some consumers may have been using Path as a way to record a manual path traversal, then using it to traverse that path programatically later (such as the user selecting a folder inside a root, but only once).

Adding a TraverseRelativePathAsync extension method would help do programmatic navigation, but we should also have a way to create that path automatically since Path isn't supplied. Any ideas? @yoshiask @d2dyno1

Instead passing an actual path, consumers could pass a list of names (e.g. /AppData/Local becomes { "AppData", "Local"}). Or, if you want the extension to feel like a path, standardize the directory separator to / and take a string. Then implementations that can skip straight to a path (like SystemFolder) can use the path directly, and the extension method can split on directory separators and traverse for impl. that can't.

commented

@yoshiask That doesn't solve our issue, though. We took away ready-to-use data that allowed you to navigate a folder structure, and we don't have an alternative. We need to come up with one.

It's tricky, because the only way to do this automatically and transparently is to wrap around every GetItemsAsync and GetParentAsync call and build the path as navigation happens. Folder traversal recording, I guess.

There's a lot of ways we could approach that, but most of the obvious ones come with caveats and quirks. Need time to think, I'll be back when I have something concrete.

commented

The only job a Path really serves that can't be replaced is to act as a "map" so you can find your way back to that file from the root.

To replace that functionality without making the implementor expose a Path, we could do something like this:

public interface IFolderCanFastGetItemRecursive : IFolder
{
    public Task<IStorable> GetItemRecursiveAsync(string id, CancellationToken cancellationToken = default);
}

public static class FolderExtensions
{
    public async Task<IStorable> GetItemRecursiveAsync(this IFolder item, string id, CancellationToken cancellationToken = default);
    {
        if (item is IFolderCanFastGetItemRecursive fastPath)
            return await fastPath.GetItemRecursiveAsync(id, cancellationToken);

        // TODO: Crawl the storage tree as a fallback.
        // Depth first or breadth first? In parallel? Needs options.
    }  
}

This allows the consumer to quick get an item that might be nested within the provided IFolder.

Implementors can make sure it's fast by implementing IFolderCanFastGetItemRecursive, and as a fallback we do a manual crawl of the folder until we find what we need.

commented

@yoshiask found one more bit of functionality that Path provided which we'll need to replace - creating relative paths between 2 folders.

To remedy this, I propose adding:

  • A GetRelativePathToAsync extension method, to generate a relative path between 2 folders.
  • A GetItemByRelativePathAsync extension method, to traverse a relative path from one storable to another.

For GetRelativePathToAsync:

/// <summary>
/// Crawls the ancestors of <paramref cref="to" /> until <paramref cref="from"/> is found, then returns the constructed relative path.
/// </summary>
/// <remarks>Example code. Not tested.</remarks>
public static async Task<string> GetRelativePathToAsync(this IFolder from, IFolderChild to)
{
    var pathComponents = new List<string>()
    {
        to.Name,
    };

    await AddItemParentToPathAsync(to);

    async Task AddItemParentToPathAsync(IFolderChild item)
    {
         // TODO: Handle `from` not being found as parent of `to`.
         var parent = await item.GetParentAsync();
         if (parent is not null && parent is IFolderChild child && parent.Id != from.Id)
         {
             pathComponents.InsertOrAdd(0, item.Name);
             await AddItemParentToPathAsync(child);
         }
    }

    // Relative path to a folder should end with a directory separator '/'
    if (to is IFolder)
        return $"/{string.Join(pathComponents, '/')}/";

    // Relative path to a file should end with file name
    if (to is IFile)
        return $"/{string.Join(pathComponents, '/')}";
}

For GetItemByRelativePathAsync:

/// <summary>
/// Traverses the relative path from the provided <see cref="IStorable"/> and returns the item at that path.
/// </summary>
/// <param name="from">The item to start with when traversing.</param>
/// <param name="relativePath">The path of the storable item to return, relative to the provided item.</param>
/// <param name="cancellationToken">A token to cancel the ongoing operation.</param>
/// <returns>The <see cref="IStorable"/> item found at the relative path.</returns>
/// <exception cref="ArgumentException">
/// A parent directory was specified, but the provided <see cref="IStorable"/> is not addressable.
/// Or, the provided relative path named a folder, but the item was a file. 
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">A parent folder was requested, but the storable item did not return a parent.</exception>
/// <exception cref="FileNotFoundException">A named item was specified in a folder, but the item wasn't found.</exception>
public static async Task<IStorable> GetItemByRelativePathAsync(this IStorable from, string relativePath, CancellationToken cancellationToken = default)
{
    var directorySeparatorChar = Path.DirectorySeparatorChar;

    // Traverse only one level at a time
    // But recursively, until the target has been reached.
    var pathParts = relativePath.Split(directorySeparatorChar).Where(x => !string.IsNullOrWhiteSpace(x) && x != ".").ToArray();

    // Current directory was specified.
    if (pathParts.Length == 0)
        return from;

    var nextDirectoryName = pathParts[0];
    Guard.IsNotNullOrWhiteSpace(nextDirectoryName);

    // Get parent directory.
    if (nextDirectoryName == "..")
    {
        if (from is not IAddressableStorable addressableStorable)
            throw new ArgumentException($"A parent folder was requested, but the storable item named {from.Name} is not addressable.", nameof(relativePath));

        var parent = await addressableStorable.GetParentAsync(cancellationToken);

        // If this item was the last one needed.
        if (parent is not null && pathParts.Length == 1)
            return parent;

        if (parent is null)
            throw new ArgumentOutOfRangeException(nameof(relativePath), "A parent folder was requested, but the storable item did not return a parent.");

        return await TraverseRelativePathAsync(parent, string.Join(directorySeparatorChar.ToString(), pathParts.Skip(1)));
    }

    // Get child item by name.
    if (from is not IFolder folder)
        throw new ArgumentException($"An item named {nextDirectoryName} was requested from the folder named {from.Name}, but {from.Name} is not a folder.");

    var item = await folder.GetItemsAsync(cancellationToken: cancellationToken).FirstOrDefaultAsync(x => x.Name == nextDirectoryName, cancellationToken: cancellationToken);

    if (item is null)
        throw new FileNotFoundException($"An item named {nextDirectoryName} was requested from the folder named {from.Name}, but {nextDirectoryName} wasn't found in the folder.");

    return await GetItemByRelativePathAsync(item, string.Join(directorySeparatorChar.ToString(), pathParts.Skip(1)));
}

Usage:

var relativePath = "/Users/arlog/source/dotnet/standard/OwlCore.Storage/";

var root = new SystemFolder("C:/");
var subFolder = await root.GetItemByRelativePathAsync(relativePath);

var generatedRelativePath = await root.GetRelativePathAsync(subFolder);

// Generated path should match original path 
Assert.AreEqual(relativePath, generatedRelativePath);