Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/ltree-helpers/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
The MIT License (MIT)

Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
Copyright (c) 2025 Constructive

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
6 changes: 6 additions & 0 deletions packages/ltree-helpers/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
EXTENSION = pgpm-ltree-helpers
DATA = sql/pgpm-ltree-helpers--0.21.0.sql

PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
75 changes: 75 additions & 0 deletions packages/ltree-helpers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# @pgpm/ltree-helpers

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/pgpm-modules/actions/workflows/ci.yml">
<img height="20" src="https://github.com/constructive-io/pgpm-modules/actions/workflows/ci.yml/badge.svg" />
</a>
<a href="https://github.com/constructive-io/pgpm-modules/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
<a href="https://www.npmjs.com/package/@pgpm/ltree-helpers"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/pgpm-modules?filename=packages%2Fltree-helpers%2Fpackage.json"/></a>
</p>

Slash-path to ltree/lquery conversion helpers for PostgreSQL.

## Overview

`@pgpm/ltree-helpers` provides simple SQL functions for converting between user-facing slash-delimited paths (like `/projects/alpha/docs`) and PostgreSQL's `ltree`/`lquery` types. This keeps ltree as an implementation detail while exposing a familiar filesystem-style path API.

## Features

- **Slash to ltree**: Convert `/projects/alpha/docs` to `projects.alpha.docs`
- **ltree to slash**: Convert `projects.alpha.docs` to `/projects/alpha/docs`
- **Glob to lquery**: Convert `/projects/*/docs` to `projects.*.docs` and `/**` to `.*{1,}`
- **Pure SQL**: All functions are `IMMUTABLE STRICT` for maximum performance and plan caching
- **Own schema**: Functions live in the `ltree_helpers` schema, not in `public`

## Installation

```bash
cd packages/my-module
pgpm install @pgpm/ltree-helpers
```

## Usage

```sql
-- Slash path to ltree
SELECT ltree_helpers.to_path('/projects/alpha/docs');
-- => 'projects.alpha.docs'::ltree

-- ltree to slash path
SELECT ltree_helpers.to_slash('projects.alpha.docs'::ltree);
-- => '/projects/alpha/docs'

-- Glob to lquery (single-level wildcard)
SELECT ltree_helpers.to_query('/projects/*/docs');
-- => 'projects.*.docs'::lquery

-- Glob to lquery (recursive wildcard)
SELECT ltree_helpers.to_query('/projects/**');
-- => 'projects.*{1,}'::lquery

-- Use with ltree operators
SELECT * FROM files
WHERE path <@ ltree_helpers.to_path('/projects/alpha');

-- Glob matching
SELECT * FROM files
WHERE path ~ ltree_helpers.to_query('/projects/*/docs');
```

## API

| Function | Signature | Description |
|----------|-----------|-------------|
| `ltree_helpers.to_path` | `(text) -> ltree` | Slash path to ltree |
| `ltree_helpers.to_slash` | `(ltree) -> text` | ltree to slash path |
| `ltree_helpers.to_query` | `(text) -> lquery` | Glob pattern to lquery |

## Dependencies

- `ltree` (PostgreSQL contrib extension)
- `pgpm-verify`
92 changes: 92 additions & 0 deletions packages/ltree-helpers/__tests__/ltree-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { getConnections, PgTestClient } from 'pgsql-test';

let pg: PgTestClient;
let teardown: () => Promise<void>;

beforeAll(async () => {
({ pg, teardown } = await getConnections());
});

afterAll(async () => {
await teardown();
});

describe('to_path', () => {
it('converts slash path with leading slash', async () => {
const { to_path } = await pg.one(
`SELECT ltree_helpers.to_path($1)::text AS to_path`,
['/projects/alpha/docs']
);
expect(to_path).toBe('projects.alpha.docs');
});

it('converts slash path without leading slash', async () => {
const { to_path } = await pg.one(
`SELECT ltree_helpers.to_path($1)::text AS to_path`,
['projects/alpha']
);
expect(to_path).toBe('projects.alpha');
});

it('converts single segment', async () => {
const { to_path } = await pg.one(
`SELECT ltree_helpers.to_path($1)::text AS to_path`,
['/root']
);
expect(to_path).toBe('root');
});
});

describe('to_slash', () => {
it('converts ltree to slash path', async () => {
const { to_slash } = await pg.one(
`SELECT ltree_helpers.to_slash($1::ltree) AS to_slash`,
['projects.alpha.docs']
);
expect(to_slash).toBe('/projects/alpha/docs');
});

it('converts single label', async () => {
const { to_slash } = await pg.one(
`SELECT ltree_helpers.to_slash($1::ltree) AS to_slash`,
['root']
);
expect(to_slash).toBe('/root');
});
});

describe('to_query', () => {
it('converts single-level wildcard', async () => {
const { to_query } = await pg.one(
`SELECT ltree_helpers.to_query($1)::text AS to_query`,
['/projects/*/docs']
);
expect(to_query).toBe('projects.*.docs');
});

it('converts recursive wildcard', async () => {
const { to_query } = await pg.one(
`SELECT ltree_helpers.to_query($1)::text AS to_query`,
['/projects/**']
);
expect(to_query).toBe('projects.*{1,}');
});

it('converts exact path (no wildcards)', async () => {
const { to_query } = await pg.one(
`SELECT ltree_helpers.to_query($1)::text AS to_query`,
['/projects/alpha']
);
expect(to_query).toBe('projects.alpha');
});
});

describe('roundtrip', () => {
it('to_path then to_slash returns original path', async () => {
const { roundtrip } = await pg.one(
`SELECT ltree_helpers.to_slash(ltree_helpers.to_path($1)) AS roundtrip`,
['/projects/alpha/docs']
);
expect(roundtrip).toBe('/projects/alpha/docs');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Deploy schemas/ltree_helpers/procedures/to_path to pg

-- requires: schemas/ltree_helpers/schema

BEGIN;

-- Convert a slash-delimited path to an ltree value.
-- '/projects/alpha/docs' => 'projects.alpha.docs'
-- 'projects/alpha' => 'projects.alpha'
CREATE FUNCTION ltree_helpers.to_path(
slash_path text
) RETURNS ltree AS $$
SELECT replace(ltrim(slash_path, '/'), '/', '.')::ltree;
$$ LANGUAGE sql IMMUTABLE STRICT;

COMMIT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Deploy schemas/ltree_helpers/procedures/to_query to pg

-- requires: schemas/ltree_helpers/schema

BEGIN;

-- Convert a glob-style path to an lquery value.
-- '/projects/*/docs' => 'projects.*.docs'
-- '/projects/**' => 'projects.*{1,}'
CREATE FUNCTION ltree_helpers.to_query(
glob text
) RETURNS lquery AS $$
SELECT replace(
replace(ltrim(glob, '/'), '**', '*{1,}'),
'/', '.'
)::lquery;
$$ LANGUAGE sql IMMUTABLE STRICT;

COMMIT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Deploy schemas/ltree_helpers/procedures/to_slash to pg

-- requires: schemas/ltree_helpers/schema

BEGIN;

-- Convert an ltree value to a slash-delimited path.
-- 'projects.alpha.docs' => '/projects/alpha/docs'
CREATE FUNCTION ltree_helpers.to_slash(
lpath ltree
) RETURNS text AS $$
SELECT '/' || replace(lpath::text, '.', '/');
$$ LANGUAGE sql IMMUTABLE STRICT;

COMMIT;
15 changes: 15 additions & 0 deletions packages/ltree-helpers/deploy/schemas/ltree_helpers/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Deploy schemas/ltree_helpers/schema to pg


BEGIN;

CREATE SCHEMA ltree_helpers;

GRANT USAGE ON SCHEMA ltree_helpers
TO authenticated, anonymous;

ALTER DEFAULT PRIVILEGES IN SCHEMA ltree_helpers
GRANT EXECUTE ON FUNCTIONS
TO authenticated;

COMMIT;
15 changes: 15 additions & 0 deletions packages/ltree-helpers/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',

// Match both __tests__ and colocated test files
testMatch: ['**/?(*.)+(test|spec).{ts,tsx,js,jsx}'],

// Ignore build artifacts and type declarations
testPathIgnorePatterns: ['/dist/', '\\.d\\.ts$'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
watchPathIgnorePatterns: ['/dist/'],

moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};
38 changes: 38 additions & 0 deletions packages/ltree-helpers/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@pgpm/ltree-helpers",
"version": "0.21.0",
"description": "Slash-path to ltree/lquery conversion helpers for PostgreSQL",
"author": "Dan Lynch <pyramation@gmail.com>",
"contributors": [
"Constructive <developers@constructive.io>"
],
"keywords": [
"postgresql",
"pgpm",
"ltree",
"filesystem",
"paths"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"bundle": "pgpm package",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@pgpm/verify": "workspace:*"
},
"devDependencies": {
"pgpm": "^4.16.6"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/pgpm-modules"
},
"homepage": "https://github.com/constructive-io/pgpm-modules",
"bugs": {
"url": "https://github.com/constructive-io/pgpm-modules/issues"
}
}
7 changes: 7 additions & 0 deletions packages/ltree-helpers/pgpm-ltree-helpers.control
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# pgpm-ltree-helpers extension
comment = 'pgpm-ltree-helpers extension'
default_version = '0.21.0'
module_pathname = '$libdir/pgpm-ltree-helpers'
requires = 'plpgsql,ltree,pgpm-verify'
relocatable = false
superuser = false
8 changes: 8 additions & 0 deletions packages/ltree-helpers/pgpm.plan
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
%syntax-version=1.0.0
%project=pgpm-ltree-helpers
%uri=pgpm-ltree-helpers

schemas/ltree_helpers/schema 2026-05-01T06:00:00Z devin <devin@cognition.ai> # add schemas/ltree_helpers/schema
schemas/ltree_helpers/procedures/to_path [schemas/ltree_helpers/schema] 2026-05-01T06:00:00Z devin <devin@cognition.ai> # add schemas/ltree_helpers/procedures/to_path
schemas/ltree_helpers/procedures/to_slash [schemas/ltree_helpers/schema] 2026-05-01T06:00:00Z devin <devin@cognition.ai> # add schemas/ltree_helpers/procedures/to_slash
schemas/ltree_helpers/procedures/to_query [schemas/ltree_helpers/schema] 2026-05-01T06:00:00Z devin <devin@cognition.ai> # add schemas/ltree_helpers/procedures/to_query
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Revert schemas/ltree_helpers/procedures/to_path from pg

BEGIN;

DROP FUNCTION ltree_helpers.to_path;

COMMIT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Revert schemas/ltree_helpers/procedures/to_query from pg

BEGIN;

DROP FUNCTION ltree_helpers.to_query;

COMMIT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Revert schemas/ltree_helpers/procedures/to_slash from pg

BEGIN;

DROP FUNCTION ltree_helpers.to_slash;

COMMIT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Revert schemas/ltree_helpers/schema from pg

BEGIN;

DROP SCHEMA ltree_helpers CASCADE;

COMMIT;
22 changes: 22 additions & 0 deletions packages/ltree-helpers/sql/pgpm-ltree-helpers--0.21.0.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
\echo Use "CREATE EXTENSION pgpm-ltree-helpers" to load this file. \quit
CREATE SCHEMA ltree_helpers;

GRANT USAGE ON SCHEMA ltree_helpers TO authenticated, anonymous;

ALTER DEFAULT PRIVILEGES IN SCHEMA ltree_helpers
GRANT EXECUTE ON FUNCTIONS TO authenticated;

CREATE FUNCTION ltree_helpers.to_path(slash_path text) RETURNS ltree AS $EOFCODE$
SELECT replace(ltrim(slash_path, '/'), '/', '.')::ltree;
$EOFCODE$ LANGUAGE sql IMMUTABLE STRICT;

CREATE FUNCTION ltree_helpers.to_slash(lpath ltree) RETURNS text AS $EOFCODE$
SELECT '/' || replace(lpath::text, '.', '/');
$EOFCODE$ LANGUAGE sql IMMUTABLE STRICT;

CREATE FUNCTION ltree_helpers.to_query(glob text) RETURNS lquery AS $EOFCODE$
SELECT replace(
replace(ltrim(glob, '/'), '**', '*{1,}'),
'/', '.'
)::lquery;
$EOFCODE$ LANGUAGE sql IMMUTABLE STRICT;
Loading
Loading