Location.java

// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

package org.apache.doris.fs;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Objects;

/**
 * Immutable value object representing a storage location (URI).
 * <p>
 * Replaces bare {@code String} path parameters and the thin {@link org.apache.doris.fs.io.ParsedPath}
 * wrapper throughout the filesystem API. Does NOT include StorageProperties lookup logic
 * (that remains in {@link org.apache.doris.common.util.LocationPath}).
 * <p>
 * Examples:
 * <pre>
 *   Location loc = Location.of("s3://my-bucket/path/to/file.parquet");
 *   loc.scheme()    // "s3"
 *   loc.authority() // "my-bucket"
 *   loc.path()      // "/path/to/file.parquet"
 *
 *   Location child = loc.parent().resolve("other.parquet");
 *   // s3://my-bucket/path/to/other.parquet
 * </pre>
 */
public final class Location {

    private final String location;

    // Lazily parsed URI — avoids URI parsing cost for hot paths that only need toString()
    private volatile URI parsedUri;

    private Location(String location) {
        Objects.requireNonNull(location, "location must not be null");
        if (location.isEmpty()) {
            throw new IllegalArgumentException("location must not be empty");
        }
        this.location = location;
    }

    /**
     * Creates a Location from a raw URI string.
     *
     * @throws IllegalArgumentException if the string is null, empty, or malformed
     */
    public static Location of(String location) {
        return new Location(location);
    }

    /** URI scheme, e.g., "s3", "hdfs", "file". Returns empty string if no scheme. */
    public String scheme() {
        URI uri = ensureParsed();
        String s = uri.getScheme();
        return s == null ? "" : s;
    }

    /** URI authority (host + optional port), e.g., "bucket-name" or "namenode:8020". */
    public String authority() {
        URI uri = ensureParsed();
        String a = uri.getAuthority();
        return a == null ? "" : a;
    }

    /** The path component of the URI, e.g., "/warehouse/db/table". */
    public String path() {
        return ensureParsed().getPath();
    }

    /**
     * Returns the parent location (directory containing this location).
     * <p>
     * e.g., {@code s3://bucket/a/b/c} → {@code s3://bucket/a/b}
     */
    public Location parent() {
        URI uri = ensureParsed();
        String rawPath = uri.getRawPath();
        if (rawPath == null || rawPath.isEmpty() || rawPath.equals("/")) {
            throw new IllegalStateException("Location has no parent: " + location);
        }
        // Strip trailing slash before finding last separator
        String normalized = rawPath.endsWith("/") ? rawPath.substring(0, rawPath.length() - 1) : rawPath;
        int lastSlash = normalized.lastIndexOf('/');
        String parentPath = lastSlash <= 0 ? "/" : normalized.substring(0, lastSlash);
        try {
            URI parentUri = new URI(uri.getScheme(), uri.getAuthority(), parentPath, null, null);
            return new Location(parentUri.toString());
        } catch (URISyntaxException e) {
            throw new IllegalStateException("Cannot compute parent of: " + location, e);
        }
    }

    /**
     * Appends a relative path segment to this location, returning a new child Location.
     * <p>
     * e.g., {@code Location.of("s3://bucket/a").resolve("b/c")} → {@code s3://bucket/a/b/c}
     */
    public Location resolve(String relativePath) {
        Objects.requireNonNull(relativePath, "relativePath must not be null");
        String base = location.endsWith("/") ? location : location + "/";
        String child = relativePath.startsWith("/") ? relativePath.substring(1) : relativePath;
        return new Location(base + child);
    }

    /**
     * Returns the last path component (filename or directory name).
     */
    public String name() {
        String p = path();
        if (p.isEmpty() || p.equals("/")) {
            return authority(); // root: use authority as name
        }
        String trimmed = p.endsWith("/") ? p.substring(0, p.length() - 1) : p;
        int lastSlash = trimmed.lastIndexOf('/');
        return lastSlash < 0 ? trimmed : trimmed.substring(lastSlash + 1);
    }

    /** The original location string as provided. */
    @Override
    public String toString() {
        return location;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Location)) {
            return false;
        }
        return location.equals(((Location) o).location);
    }

    @Override
    public int hashCode() {
        return location.hashCode();
    }

    private URI ensureParsed() {
        if (parsedUri == null) {
            synchronized (this) {
                if (parsedUri == null) {
                    try {
                        parsedUri = new URI(location);
                    } catch (URISyntaxException e) {
                        throw new IllegalArgumentException("Invalid location URI: " + location, e);
                    }
                }
            }
        }
        return parsedUri;
    }
}