Share

lsoh: A Modern Bash Utility for Discovering and Managing Oracle Homes

lsoh2

As a DBA, one of the most common questions I’m asked – especially during patching cycles or audits – is:

What Oracle Homes do we have on this server?

at first glance, this sounds trivial, as DB and Grid homes can easily be seen from /etc/oratab file. But in reality:

  • oratab doesn’t show all the OHs installed on a server
  • oratab only shows OHs for Grid Infrastructure and Oracle Database, and it doesn’t reflect all installed homes
  • manaul tracking of what’s installed on a server is unreliable

That’s when I came across a great post by Ludovico Caldara: Getting the Oracle Homes in a server from the oraInventory

The Original Idea

The core concept is simple and brilliant:

  • The Oracle central inventory (oraInventory) keeps track of all installed Oracle Homes on a server.
  • It contains an inventory.xml file listing:
    • Oracle Home names
    • locations
    • installation metadata

Instead of relying on oratab, Ludovico’s approach is to parse inventory.xml and inspect each Oracle Home’s local inventory to extract version and edition. His bash function demonstrated that even with simple tools like grep, awk, and tr, you can extract meaningful Oracle Home metadata directly from the inventory.

Taking it Further

While the original script is very useful, I wanted something that could serve as a daily DBA utility – especially useful for:

  • patch planning
  • environment audits
  • automation pipelines
  • quick triage during incidents

So I extended the concept into a much more feature-rich CLI-style function, which I call lsoh2.

Key Enhancement

Here are the major improvements I added:

1. Intelligent Classification

The original function only looked at Grid and Database homes, but I’ve expanded it to automatically identify:

  • GRID (active vs installed)
  • DB homes (including edition like EE)
  • OEM agent
  • Oracle client
  • OGG (Classic / Microservices)
  • Middleware (OHS, OCMN)

This saves time compared to manual inspection.

2. Rich Output Formats

Instead of single statis output, the function now supports:

  • Default → formatted table (via printTable)
  • --csv → Excel/report-friendly output
  • --json → structured output for automation pipelines
  • --raw → minimal, script-friendly output

3. Advanced Filtering

You can now slice the data easily:

  • --grep <pattern> → include matches
  • --grep -v <pattern> → exclude matches
  • --type <db|grid|ogg|agent|...> → filter by Oracle Home type

This turns the function into something closer to a UNIX-style query tool.

4. Column Selection

Using --column <column_name> you can chose which column to be included in the output only, which is especially useful for automation (e.g., patch loops or health checks).

5. Automation-Friendly (--raw)

The --raw mode strips everything down to:

  • no headers
  • no formatting
  • pure values

Examples

[oracle@ol8db1902 ~]$ lsoh2
  +----------------+------------------------------------------+---------------+---------------+
  |  NAME          |  LOCATION                                |  VERSION      |  TYPE         |
  +----------------+------------------------------------------+---------------+---------------+
  |  OraHome1      |  /u01/app/oracle/product/19.0.0/gg_1     |  19.1.0.0.0   |  OGG CA       |
  |  OraHome4      |  /u01/app/oracle/product/23.26.2/ogg     |  23.26.2.0.1  |  OGG MA       |
  |  OraHome2      |  /u01/app/OEMCC/agent_24.1.0.0.0         |  24.1.0.0.0   |  AGNT         |
  |  OraGI19Home2  |  /u01/app/oracle/product/19.30.0/grid    |  19.30.0.0.0  |  GRID INSTLD  |
  |  OraDB19Home2  |  /u01/app/oracle/product/19.30.0/db_abc  |  19.30.0.0.0  |  DBMS EE      |
  |  OraGI19Home1  |  /u01/app/oracle/product/19.31.0/grid    |  19.31.0.0.0  |  GRID ACTIVE  |
  |  OraDB19Home1  |  /u01/app/oracle/product/19.31.0/db_abc  |  19.31.0.0.0  |  DBMS EE      |
  +----------------+------------------------------------------+---------------+---------------+

CSV output

[oracle@ol8db1902 ~]$ lsoh2 --csv
NAME,LOCATION,VERSION,TYPE
OraHome1,/u01/app/oracle/product/19.0.0/gg_1,19.1.0.0.0,OGG CA
OraHome4,/u01/app/oracle/product/23.26.2/ogg,23.26.2.0.1,OGG MA
OraHome2,/u01/app/OEMCC/agent_24.1.0.0.0,24.1.0.0.0,AGNT
OraGI19Home2,/u01/app/oracle/product/19.30.0/grid,19.30.0.0.0,GRID INSTLD
OraDB19Home2,/u01/app/oracle/product/19.30.0/db_abc,19.30.0.0.0,DBMS EE
OraGI19Home1,/u01/app/oracle/product/19.31.0/grid,19.31.0.0.0,GRID ACTIVE
OraDB19Home1,/u01/app/oracle/product/19.31.0/db_abc,19.31.0.0.0,DBMS EE

JSON output

[oracle@ol8db1902 ~]$ lsoh2 --json
[
  {"NAME":"OraHome1","LOCATION":"/u01/app/oracle/product/19.0.0/gg_1","VERSION":"19.1.0.0.0","TYPE":"OGG CA"},
  {"NAME":"OraHome4","LOCATION":"/u01/app/oracle/product/23.26.2/ogg","VERSION":"23.26.2.0.1","TYPE":"OGG MA"},
  {"NAME":"OraHome2","LOCATION":"/u01/app/OEMCC/agent_24.1.0.0.0","VERSION":"24.1.0.0.0","TYPE":"AGNT"},
  {"NAME":"OraGI19Home2","LOCATION":"/u01/app/oracle/product/19.30.0/grid","VERSION":"19.30.0.0.0","TYPE":"GRID INSTLD"},
  {"NAME":"OraDB19Home2","LOCATION":"/u01/app/oracle/product/19.30.0/db_abc","VERSION":"19.30.0.0.0","TYPE":"DBMS EE"},
  {"NAME":"OraGI19Home1","LOCATION":"/u01/app/oracle/product/19.31.0/grid","VERSION":"19.31.0.0.0","TYPE":"GRID ACTIVE"},
  {"NAME":"OraDB19Home1","LOCATION":"/u01/app/oracle/product/19.31.0/db_abc","VERSION":"19.31.0.0.0","TYPE":"DBMS EE"}
]

Filter by type

[oracle@ol8db1902 ~]$ lsoh2 --type grid
  +----------------+----------------------------------------+---------------+---------------+
  |  NAME          |  LOCATION                              |  VERSION      |  TYPE         |
  +----------------+----------------------------------------+---------------+---------------+
  |  OraGI19Home2  |  /u01/app/oracle/product/19.30.0/grid  |  19.30.0.0.0  |  GRID INSTLD  |
  |  OraGI19Home1  |  /u01/app/oracle/product/19.31.0/grid  |  19.31.0.0.0  |  GRID ACTIVE  |
  +----------------+----------------------------------------+---------------+---------------+

Filter using grep

[oracle@ol8db1902 ~]$ lsoh2 --grep "db|grid"
  +----------------+------------------------------------------+---------------+---------------+
  |  NAME          |  LOCATION                                |  VERSION      |  TYPE         |
  +----------------+------------------------------------------+---------------+---------------+
  |  OraGI19Home2  |  /u01/app/oracle/product/19.30.0/grid    |  19.30.0.0.0  |  GRID INSTLD  |
  |  OraDB19Home2  |  /u01/app/oracle/product/19.30.0/db_abc  |  19.30.0.0.0  |  DBMS EE      |
  |  OraGI19Home1  |  /u01/app/oracle/product/19.31.0/grid    |  19.31.0.0.0  |  GRID ACTIVE  |
  |  OraDB19Home1  |  /u01/app/oracle/product/19.31.0/db_abc  |  19.31.0.0.0  |  DBMS EE      |
  +----------------+------------------------------------------+---------------+---------------+

[oracle@ol8db1902 ~]$ lsoh2 --grep -v 19.30
  +----------------+------------------------------------------+---------------+---------------+
  |  NAME          |  LOCATION                                |  VERSION      |  TYPE         |
  +----------------+------------------------------------------+---------------+---------------+
  |  OraHome1      |  /u01/app/oracle/product/19.0.0/gg_1     |  19.1.0.0.0   |  OGG CA       |
  |  OraHome4      |  /u01/app/oracle/product/23.26.2/ogg     |  23.26.2.0.1  |  OGG MA       |
  |  OraHome2      |  /u01/app/OEMCC/agent_24.1.0.0.0         |  24.1.0.0.0   |  AGNT         |
  |  OraGI19Home1  |  /u01/app/oracle/product/19.31.0/grid    |  19.31.0.0.0  |  GRID ACTIVE  |
  |  OraDB19Home1  |  /u01/app/oracle/product/19.31.0/db_abc  |  19.31.0.0.0  |  DBMS EE      |
  +----------------+------------------------------------------+---------------+---------------+
  

Supres header

[oracle@ol8db1902 ~]$ lsoh2 --type db --column location --csv --no-header
/u01/app/oracle/product/19.30.0/db_abc
/u01/app/oracle/product/19.31.0/db_abc

raw format

[oracle@ol8db1902 ~]$ lsoh2 --type agent --raw
OraHome2        /u01/app/OEMCC/agent_24.1.0.0.0 	24.1.0.0.0      AGNT

Show help

[oracle@ol8db1902 ~]$ lsoh2 -h
Usage: lsoh2 [options]

Options:
  --csv                       Output in CSV format
  --json                      Output in JSON format
  --raw                       Script-friendly raw output (no header, no table formatting)
  --grep PATTERN              Include only rows matching PATTERN (case-insensitive regex)
  --grep -v PATTERN           Exclude rows matching PATTERN (case-insensitive regex)
  --grep-v PATTERN            Same as: --grep -v PATTERN
  --type TYPE                 Filter by TYPE
  --column COL                Show only one column: name|location|version|type
  --no-header                 Suppress header row (table/csv only)
  -h, --help                  Show this help

TYPE values:
  db | dbms                   Database homes (TYPE begins with DBMS)
  grid                        Grid homes
  ogg                         GoldenGate homes
  agent | agnt                OEM agent homes
  client | clnt               Oracle client homes
  ohs                         Oracle HTTP Server homes
  ocmn                        Oracle common homes
  <regex>                     Any regex matched against TYPE column

Notes:
  - --raw with --column prints one value per line
  - --raw without --column prints tab-separated rows
  - --no-header has no effect on --json or --raw

Examples:
  lsoh2
  lsoh2 --csv
  lsoh2 --json
  lsoh2 --grep grid
  lsoh2 --grep -v grid
  lsoh2 --type db
  lsoh2 --type grid --no-header
  lsoh2 --column location
  lsoh2 --column location --raw
  lsoh2 --type db --column version --csv
  lsoh2 --type ogg --raw

Identifying mode OHs

[oracle@ol8db1901 pb]$ lsoh2
  +--------------------+-------------------------------------------------------+---------------+---------------+
  |  NAME              |  LOCATION                                             |  VERSION      |  TYPE         |
  +--------------------+-------------------------------------------------------+---------------+---------------+
  |  OH1216539181      |  /u01/app/oracle/product/11.1.1.6/OHS                 |  11.1.1.9.0   |  OHS          |
  |  OH814941025       |  /u01/app/oracle/product/11.1.1.6/oracle_common       |  11.1.1.2.0   |  OCMN         |
  |  OraClient12Home1  |  /u01/app/oracle/ora32client/product/12.1.0/client_1  |  12.1.0.2.0   |  CLNT         |
  |  OraHome9          |  /u01/app/OEMCC/agent_24.1.0.0.0                      |  24.1.0.0.0   |  AGNT         |
  |  OraGI19Home3      |  /u01/app/oracle/product/19.30.0/grid                 |  19.30.0.0.0  |  GRID INSTLD  |
  |  OraDB19Home7      |  /u01/app/oracle/product/19.30.0/db_app1              |  19.30.0.0.0  |  DBMS EE      |
  |  OraDB19Home8      |  /u01/app/oracle/product/19.30.0/db_shared1           |  19.30.0.0.0  |  DBMS EE      |
  |  OraDB19Home9      |  /u01/app/oracle/product/19.30.0/db_shared2           |  19.30.0.0.0  |  DBMS EE      |
  |  OraGI19Home1      |  /u01/app/oracle/product/19.31.0/grid                 |  19.31.0.0.0  |  GRID ACTIVE  |
  |  OraDB19Home1      |  /u01/app/oracle/product/19.31.0/db_shared1           |  19.31.0.0.0  |  DBMS EE      |
  |  OraDB19Home2      |  /u01/app/oracle/product/19.31.0/db_shared2           |  19.31.0.0.0  |  DBMS EE      |
  |  OraDB19Home3      |  /u01/app/oracle/product/19.31.0/db_app1              |  19.31.0.0.0  |  DBMS EE      |
  +--------------------+-------------------------------------------------------+---------------+---------------+

Current Version of the Function

There is still so much more that could be added/enhanced here, but I would like to share the function with you for you to test. Please add any issues or recommendations you have in the comments and I’ll work on adding them to the function.

lsoh2() {

    local output_mode="table"
    local grep_pattern=""
    local grep_invert=0
    local type_filter=""
    local no_header=0
    local raw_mode=0
    local column_filter=""
    local mode_count=0

    # JSON escape helper
    _lsoh2_json_escape() {
        local s="$1"
        s=${s//\\/\\\\}
        s=${s//\"/\\\"}
        s=${s//$'\n'/\\n}
        s=${s//$'\r'/\\r}
        s=${s//$'\t'/\\t}
        printf '%s' "$s"
    }

    # TYPE matcher
    _lsoh2_type_match() {
        local actual_type="$1"
        local requested_type="$2"

        shopt -s nocasematch
        case "$requested_type" in
            db|dbms)
                [[ "$actual_type" =~ ^DBMS ]]
                ;;
            grid)
                [[ "$actual_type" =~ ^GRID ]]
                ;;
            ogg)
                [[ "$actual_type" =~ ^OGG ]]
                ;;
            agent|agnt)
                [[ "$actual_type" == "AGNT" ]]
                ;;
            client|clnt)
                [[ "$actual_type" == "CLNT" ]]
                ;;
            ohs)
                [[ "$actual_type" == "OHS" ]]
                ;;
            ocmn)
                [[ "$actual_type" == "OCMN" ]]
                ;;
            *)
                printf '%s\n' "$actual_type" | grep -Eiq -- "$requested_type"
                ;;
        esac
        local rc=$?
        shopt -u nocasematch
        return $rc
    }

    # Extract requested column from row
    _lsoh2_get_column_value() {
        local row="$1"
        local col="$2"
        local h l v e
        IFS='|' read -r h l v e <<< "$row"

        case "$col" in
            name)      printf '%s' "$h" ;;
            location)  printf '%s' "$l" ;;
            version)   printf '%s' "$v" ;;
            type)      printf '%s' "$e" ;;
            *)
                return 1
                ;;
        esac
    }

    # Render table helper
    _lsoh2_render_table() {
        local csv_data="$1"
        local suppress_header="$2"

        if type printTable >/dev/null 2>&1; then
            if [[ "$suppress_header" -eq 1 ]]; then
                # Keep top border, strip header and header separator, keep rows + bottom border
                printTable "," "$csv_data" | awk 'NR==1 || NR>3'
            else
                printTable "," "$csv_data"
            fi
        else
            # Fallback to CSV if printTable is not available
            if [[ "$suppress_header" -eq 1 ]]; then
                printf '%s\n' "$csv_data" | tail -n +2
            else
                printf '%s\n' "$csv_data"
            fi
        fi
    }

    # Parse arguments
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --csv)
                output_mode="csv"
                ((mode_count++))
                ;;
            --json)
                output_mode="json"
                ((mode_count++))
                ;;
            --raw)
                raw_mode=1
                ((mode_count++))
                ;;
            --type)
                shift
                [[ -z "$1" ]] && {
                    echo "Usage: lsoh2 [--csv|--json|--raw] [--grep <pattern>|--grep -v <pattern>] [--type <type>] [--column <name|location|version|type>] [--no-header]"
                    return 1
                }
                type_filter="$1"
                ;;
            --column)
                shift
                [[ -z "$1" ]] && {
                    echo "Usage: lsoh2 --column <name|location|version|type>"
                    return 1
                }
                column_filter=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
                case "$column_filter" in
                    name|location|version|type) ;;
                    *)
                        echo "Invalid --column value: $1"
                        echo "Valid values: name, location, version, type"
                        return 1
                        ;;
                esac
                ;;
            --no-header)
                no_header=1
                ;;
            --grep)
                shift
                [[ -z "$1" ]] && {
                    echo "Usage: lsoh2 [--grep <pattern>|--grep -v <pattern>]"
                    return 1
                }

                if [[ "$1" == "-v" ]]; then
                    grep_invert=1
                    shift
                    [[ -z "$1" ]] && {
                        echo "Usage: lsoh2 --grep -v <pattern>"
                        return 1
                    }
                fi

                grep_pattern="$1"
                ;;
            --grep-v)
                shift
                [[ -z "$1" ]] && {
                    echo "Usage: lsoh2 --grep-v <pattern>"
                    return 1
                }
                grep_pattern="$1"
                grep_invert=1
                ;;
            -h|--help)
                cat <<'EOF'
Usage: lsoh2 [options]

Options:
  --csv                       Output in CSV format
  --json                      Output in JSON format
  --raw                       Script-friendly raw output (no header, no table formatting)
  --grep PATTERN              Include only rows matching PATTERN (case-insensitive regex)
  --grep -v PATTERN           Exclude rows matching PATTERN (case-insensitive regex)
  --grep-v PATTERN            Same as: --grep -v PATTERN
  --type TYPE                 Filter by TYPE
  --column COL                Show only one column: name|location|version|type
  --no-header                 Suppress header row (table/csv only)
  -h, --help                  Show this help

TYPE values:
  db | dbms                   Database homes (TYPE begins with DBMS)
  grid                        Grid homes
  ogg                         GoldenGate homes
  agent | agnt                OEM agent homes
  client | clnt               Oracle client homes
  ohs                         Oracle HTTP Server homes
  ocmn                        Oracle common homes
  <regex>                     Any regex matched against TYPE column

Notes:
  - --raw with --column prints one value per line
  - --raw without --column prints tab-separated rows
  - --no-header has no effect on --json or --raw

Examples:
  lsoh2
  lsoh2 --csv
  lsoh2 --json
  lsoh2 --grep grid
  lsoh2 --grep -v grid
  lsoh2 --type db
  lsoh2 --type grid --no-header
  lsoh2 --column location
  lsoh2 --column location --raw
  lsoh2 --type db --column version --csv
  lsoh2 --type ogg --raw
EOF
                return 0
                ;;
            *)
                echo "Unknown option: $1"
                echo "Usage: lsoh2 [--csv|--json|--raw] [--grep <pattern>|--grep -v <pattern>] [--type <type>] [--column <name|location|version|type>] [--no-header]"
                return 1
                ;;
        esac
        shift
    done

    if (( mode_count > 1 )); then
        echo "Please specify only one output mode: --csv, --json, or --raw"
        return 1
    fi

    local CENTRAL_ORAINV
    CENTRAL_ORAINV=$(grep '^inventory_loc' /etc/oraInst.loc 2>/dev/null | awk -F= '{print $2}')
    [[ -z "$CENTRAL_ORAINV" ]] && echo "Could not determine central inventory location." && return 1
    [[ ! -f "$CENTRAL_ORAINV/ContentsXML/inventory.xml" ]] && echo "No inventory file found." && return 1

    local -a rows
    local line OH OH_NAME comp_file comp_xml comp_name comp_vers
    local ORAEDITION ORAVERSION ORAMAJOR row_text

    while IFS= read -r line; do
        OH=$(echo "$line" | tr ' ' '\n' | grep '^LOC=' | awk -F\" '{print $2}')
        [[ -z "$OH" || ! -d "$OH" ]] && continue

        OH_NAME=$(echo "$line" | tr ' ' '\n' | grep '^NAME=' | awk -F\" '{print $2}')
        comp_file="$OH/inventory/ContentsXML/comps.xml"
        [[ ! -f "$comp_file" ]] && continue

        comp_xml=$(grep "COMP NAME" "$comp_file" | grep "oracle.sysman.top.agent")
        [[ -z "$comp_xml" ]] && comp_xml=$(grep "COMP NAME" "$comp_file" | head -1)
        [[ -z "$comp_xml" ]] && continue

        comp_name=$(echo "$comp_xml" | tr ' ' '\n' | grep '^NAME=' | awk -F\" '{print $2}')
        comp_vers=$(echo "$comp_xml" | tr ' ' '\n' | grep '^VER=' | awk -F\" '{print $2}')

        ORAEDITION=""
        ORAVERSION="$comp_vers"

        case "$comp_name" in
            "oracle.crs")
                [[ -x "$OH/bin/oraversion" ]] && ORAVERSION=$("$OH/bin/oraversion" -compositeVersion)
                if [[ -x "$OH/srvm/admin/getcrshome" ]] && [[ "$("$OH/srvm/admin/getcrshome" 2>/dev/null)" == "$OH" ]]; then
                    ORAEDITION="GRID ACTIVE"
                else
                    ORAEDITION="GRID INSTLD"
                fi
                ;;
            "oracle.sysman.top.agent"|"oracle.sysman.emagent.installer")
                ORAEDITION="AGNT"
                ;;
            "oracle.server")
                ORAVERSION=$(grep 'PATCH NAME="oracle.server"' "$comp_file" 2>/dev/null | tr ' ' '\n' | grep '^VER=' | awk -F\" '{print $2}')
                [[ -z "$ORAVERSION" ]] && ORAVERSION="$comp_vers"
                [[ -x "$OH/bin/oraversion" ]] && ORAVERSION=$("$OH/bin/oraversion" -compositeVersion)

                ORAMAJOR=$(echo "$ORAVERSION" | cut -d. -f1)
                case "$ORAMAJOR" in
                    11|12|19)
                        ORAEDITION="DBMS $(grep 'oracle_install_db_InstallType' "$OH"/inventory/globalvariables/oracle.server/globalvariables.xml 2>/dev/null | tr ' ' '\n' | grep VALUE | awk -F\" '{print $2}')"
                        ;;
                    10)
                        ORAEDITION="DBMS $(grep 's_serverInstallType' "$OH"/inventory/Components21/oracle.server/*/context.xml 2>/dev/null | tr ' ' '\n' | grep VALUE | awk -F\" '{print $2}')"
                        ;;
                    *)
                        ORAEDITION="DBMS"
                        ;;
                esac
                ;;
            "oracle.client")
                ORAEDITION="CLNT"
                [[ -x "$OH/bin/oraversion" ]] && ORAVERSION=$("$OH/bin/oraversion" -compositeVersion)
                ;;
            "oracle.as.webtiercd.top")
                ORAEDITION="OHS"
                [[ -x "$OH/bin/oraversion" ]] && ORAVERSION=$("$OH/bin/oraversion" -compositeVersion)
                ;;
            "oracle.as.common.top")
                ORAEDITION="OCMN"
                [[ -x "$OH/bin/oraversion" ]] && ORAVERSION=$("$OH/bin/oraversion" -compositeVersion)
                ;;
            "oracle.oggcore.top")
                ORAEDITION="OGG CA"
                [[ -x "$OH/bin/oraversion" ]] && ORAVERSION=$("$OH/bin/oraversion" -compositeVersion)
                ;;
            "oracle.oggcore.services")
                ORAEDITION="OGG MA"
                [[ -x "$OH/bin/adminclient" ]] && ORAVERSION=$("$OH/bin/adminclient" --version 2>/dev/null | grep -i version | cut -d' ' -f2 | xargs)
                ;;
            *)
                ORAEDITION=$(echo "$comp_name" | xargs)
                [[ -x "$OH/bin/oraversion" ]] && ORAVERSION=$("$OH/bin/oraversion" -compositeVersion)
                ;;
        esac

        # TYPE filter
        if [[ -n "$type_filter" ]]; then
            _lsoh2_type_match "$ORAEDITION" "$type_filter" || continue
        fi

        row_text="$OH_NAME|$OH|$ORAVERSION|$ORAEDITION"

        # GREP filter (include or exclude)
        if [[ -n "$grep_pattern" ]]; then
            if [[ "$grep_invert" -eq 1 ]]; then
                printf '%s\n' "$row_text" | grep -Eiq -- "$grep_pattern" && continue
            else
                printf '%s\n' "$row_text" | grep -Eiq -- "$grep_pattern" || continue
            fi
        fi

        rows+=("$row_text")
    done < <(grep '<HOME NAME=' "${CENTRAL_ORAINV}/ContentsXML/inventory.xml" 2>/dev/null)

    # RAW output (best for scripts)
    if [[ "$raw_mode" -eq 1 ]]; then
        local row h l v e value
        for row in "${rows[@]}"; do
            if [[ -n "$column_filter" ]]; then
                _lsoh2_get_column_value "$row" "$column_filter"
                printf '\n'
            else
                IFS='|' read -r h l v e <<< "$row"
                printf '%s\t%s\t%s\t%s\n' "$h" "$l" "$v" "$e"
            fi
        done
        return 0
    fi

    # Build CSV output
    local header csv_output row h l v e value
    if [[ -n "$column_filter" ]]; then
        header=$(printf '%s' "$column_filter" | tr '[:lower:]' '[:upper:]')
    else
        header="NAME,LOCATION,VERSION,TYPE"
    fi

    csv_output="$header"

    for row in "${rows[@]}"; do
        if [[ -n "$column_filter" ]]; then
            value=$(_lsoh2_get_column_value "$row" "$column_filter") || return 1
            csv_output+=$'\n'"$value"
        else
            IFS='|' read -r h l v e <<< "$row"
            csv_output+=$'\n'"$h,$l,$v,$e"
        fi
    done

    case "$output_mode" in
        csv)
            if [[ "$no_header" -eq 1 ]]; then
                printf '%s\n' "$csv_output" | tail -n +2
            else
                printf '%s\n' "$csv_output"
            fi
            ;;
        json)
            echo "["
            local first=1
            for row in "${rows[@]}"; do
                IFS='|' read -r h l v e <<< "$row"
                [[ $first -eq 0 ]] && echo ","

                if [[ -n "$column_filter" ]]; then
                    value=$(_lsoh2_get_column_value "$row" "$column_filter") || return 1
                    printf '  {"%s":"%s"}' \
                        "$(printf '%s' "$column_filter" | tr '[:lower:]' '[:upper:]')" \
                        "$(_lsoh2_json_escape "$value")"
                else
                    printf '  {"NAME":"%s","LOCATION":"%s","VERSION":"%s","TYPE":"%s"}' \
                        "$(_lsoh2_json_escape "$h")" \
                        "$(_lsoh2_json_escape "$l")" \
                        "$(_lsoh2_json_escape "$v")" \
                        "$(_lsoh2_json_escape "$e")"
                fi

                first=0
            done
            echo
            echo "]"
            ;;
        *)
            _lsoh2_render_table "$csv_output" "$no_header"
            ;;
    esac
}