1
0
Fork 0

Merging upstream version 1.2.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-03-04 08:16:25 +01:00
parent d6f7ec950b
commit 80be6b192c
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
6 changed files with 511 additions and 53 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [1.2.0] - 2025-02-27
- Adds interactions to list relations, indexes, constraints, and to describe relations (similar to psql's `\d+`) ([tconbeer/harlequin#586](https://github.com/tconbeer/harlequin/discussions/586) - thank you [@JPFrancoia](https://github.com/JPFrancoia)!).
## [1.1.1] - 2025-02-05
- This adapter now supports `infinity` and `-infinity` dates and timestamps by loading their values as `date[time].max` or `date[time].min` ([tconbeer/harlequin#690](https://github.com/tconbeer/harlequin/issues/690)).
@ -68,7 +72,9 @@ All notable changes to this project will be documented in this file.
- Adds a basic Postgres adapter with most common connection options.
[Unreleased]: https://github.com/tconbeer/harlequin-postgres/compare/1.1.1...HEAD
[Unreleased]: https://github.com/tconbeer/harlequin-postgres/compare/1.2.0...HEAD
[1.2.0]: https://github.com/tconbeer/harlequin-postgres/compare/1.1.1...1.2.0
[1.1.1]: https://github.com/tconbeer/harlequin-postgres/compare/1.1.0...1.1.1

View file

@ -19,7 +19,7 @@ serve:
.PHONY: psql
psql:
PGPASSWORD=for-testing psql -h localhost -p 5432 -U postgres
PGPASSWORD=for-testing psql -h localhost -p 5432 -U postgres -E
profile.html: $(wildcard src/**/*.py)
pyinstrument -r html -o profile.html --from-path harlequin -a postgres "postgresql://postgres:for-testing@localhost:5432/postgres"

View file

@ -1,73 +1,60 @@
# harlequin-postgres
This repo provides the Harlequin adapter for Postgres.
This project provides the Harlequin adapter for Postgres. For more information, see [harlequin.sh](https://harlequin.sh/docs/postgres/index).
## Installation
`harlequin-postgres` depends on `harlequin`, so installing this package will also install Harlequin.
### Using pip
To install this adapter into an activated virtual environment:
```bash
pip install harlequin-postgres
```
### Using poetry
You must install the `harlequin-postgres` package into the same environment as `harlequin`. The best and easiest way to do this is to use `uv` to install Harlequin with the `postgres` extra:
```bash
poetry add harlequin-postgres
uv tool install 'harlequin[postgres]'
```
### Using pipx
## Using Harlequin with Postgres
If you do not already have Harlequin installed:
```bash
pip install harlequin-postgres
```
If you would like to add the Postgres adapter to an existing Harlequin installation:
```bash
pipx inject harlequin harlequin-postgres
```
### As an Extra
Alternatively, you can install Harlequin with the `postgres` extra:
```bash
pip install harlequin[postgres]
```
```bash
poetry add harlequin[postgres]
```
```bash
pipx install harlequin[postgres]
```
## Usage and Configuration
You can open Harlequin with the Postgres adapter by selecting it with the `-a` option and passing a [Posgres DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING):
To connect to a Postgres database, run Harlequin with the `-a postgres` option and pass a [Posgres DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING) as an argument:
```bash
harlequin -a postgres "postgres://my-user:my-pass@localhost:5432/my-database"
```
## Connection Options
You can also pass all or parts of the connection string as separate options. The following is equivalent to the above DSN:
```bash
harlequin -a postgres -h localhost -p 5432 -U my-user --password my-pass -d my-database
```
Many more options are available; to see the full list, run:
The supported connection options are:
```bash
```
host
port
dbname
user
password
passfile
require_auth
channel_binding
connect_timeout
sslmode
sslcert
sslkey
```
For descriptions of each option, run:
```
harlequin --help
```
## Environment Variables
Harlequin's Postgres driver will load connection information from the standard `PG*` environment variables. Any options supplied at the command-line will override environment variables.
## Manual Transactions
To use Manual transaction mode, click on the label in the Run Query Bar to toggle the transaction mode from Auto to Manual.

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "harlequin-postgres"
version = "1.1.1"
version = "1.2.0"
description = "A Harlequin adapter for Postgres."
authors = ["Ted Conbeer <tconbeer@users.noreply.github.com>"]
license = "MIT"

View file

@ -13,7 +13,13 @@ from harlequin_postgres.interactions import (
execute_drop_view_statement,
execute_use_statement,
insert_columns_at_cursor,
show_describe_relation,
show_describe_table_constraints,
show_describe_table_indexes,
show_list_indexes,
show_list_objects,
show_select_star,
show_view_definition,
)
if TYPE_CHECKING:
@ -49,6 +55,7 @@ class RelationCatalogItem(InteractiveCatalogItem["HarlequinPostgresConnection"])
INTERACTIONS = [
("Insert Columns at Cursor", insert_columns_at_cursor),
("Preview Data", show_select_star),
("Describe Relation (\\d+)", show_describe_relation),
]
parent: "SchemaCatalogItem" | None = None
@ -70,6 +77,7 @@ class RelationCatalogItem(InteractiveCatalogItem["HarlequinPostgresConnection"])
class ViewCatalogItem(RelationCatalogItem):
INTERACTIONS = RelationCatalogItem.INTERACTIONS + [
("Show View Definition", show_view_definition),
("Drop View", execute_drop_view_statement),
]
@ -93,6 +101,8 @@ class ViewCatalogItem(RelationCatalogItem):
class TableCatalogItem(RelationCatalogItem):
INTERACTIONS = RelationCatalogItem.INTERACTIONS + [
("Describe Indexes", show_describe_table_indexes),
("Describe Constraints", show_describe_table_constraints),
("Drop Table", execute_drop_table_statement),
]
@ -115,10 +125,6 @@ class TableCatalogItem(RelationCatalogItem):
class TempTableCatalogItem(TableCatalogItem):
INTERACTIONS = RelationCatalogItem.INTERACTIONS + [
("Drop Table", execute_drop_table_statement),
]
@classmethod
def from_parent(
cls,
@ -164,6 +170,8 @@ class ForeignCatalogItem(TableCatalogItem):
class SchemaCatalogItem(InteractiveCatalogItem["HarlequinPostgresConnection"]):
INTERACTIONS = [
("Set Search Path", execute_use_statement),
("List Relations (\\d+)", show_list_objects),
("List Indexes (\\di+)", show_list_indexes),
("Drop Schema", execute_drop_schema_statement),
]
parent: "DatabaseCatalogItem" | None = None
@ -224,6 +232,8 @@ class SchemaCatalogItem(InteractiveCatalogItem["HarlequinPostgresConnection"]):
class DatabaseCatalogItem(InteractiveCatalogItem["HarlequinPostgresConnection"]):
INTERACTIONS = [
("List Relations (\\d+)", show_list_objects),
("List Indexes (\\di+)", show_list_indexes),
("Drop Database", execute_drop_database_statement),
]

View file

@ -8,11 +8,13 @@ from harlequin.exception import HarlequinQueryError
if TYPE_CHECKING:
from harlequin.driver import HarlequinDriver
from harlequin_duckdb.catalog import (
from harlequin_postgres.catalog import (
ColumnCatalogItem,
DatabaseCatalogItem,
RelationCatalogItem,
SchemaCatalogItem,
ViewCatalogItem,
)
@ -132,6 +134,459 @@ def show_select_star(
)
def show_list_objects(
item: "SchemaCatalogItem" | "DatabaseCatalogItem",
driver: "HarlequinDriver",
) -> None:
# sourced from psql with -E, then the following command:
# \dtvmsE+ <schema>.*
# can't use isinstance due to circular reference
if type(item).__name__ == "SchemaCatalogItem":
where_clause = f"and n.nspname = '{item.label}'"
else:
where_clause = (
"and n.nspname not in ('pg_catalog', 'pg_toast', 'information_schema')"
)
driver.insert_text_in_new_buffer(
dedent(
f"""
select
n.nspname as "Schema",
c.relname as "Name",
case c.relkind
when 'r' then 'table'
when 'v' then 'view'
when 'm' then 'materialized view'
when 'S'
then 'sequence'
when 't' then 'TOAST table'
when 'f' then 'foreign table'
when 'p' then 'partitioned table'
end as "Type",
pg_catalog.pg_get_userbyid(c.relowner) as "Owner",
case c.relpersistence
when 'p' then 'permanent'
when 't' then 'temporary'
when 'u' then 'unlogged'
end as "Persistence",
am.amname as "Access method",
pg_catalog.pg_size_pretty(pg_catalog.pg_table_size(c.oid)) as "Size",
pg_catalog.obj_description(c.oid, 'pg_class') as "Description"
from pg_catalog.pg_class c
left join pg_catalog.pg_namespace n on n.oid = c.relnamespace
left join pg_catalog.pg_am am on am.oid = c.relam
where
c.relkind IN ('r','p','t','v','m', 's', 'S', 'f')
{where_clause}
order by 1,2;
""".strip("\n")
)
)
def show_list_indexes(
item: "SchemaCatalogItem" | "DatabaseCatalogItem",
driver: "HarlequinDriver",
) -> None:
# sourced from psql with -E, then the following command:
# \dis+ <schema>.*
# can't use isinstance due to circular reference
if type(item).__name__ == "SchemaCatalogItem":
where_clause = f"and n.nspname = '{item.label}'"
else:
where_clause = (
"and n.nspname not in ('pg_catalog', 'pg_toast', 'information_schema')"
)
driver.insert_text_in_new_buffer(
dedent(
f"""
select
n.nspname as "Schema",
c.relname as "Name",
case
c.relkind
when 'i'
then 'index'
when 'I'
then 'partitioned index'
end as "Type",
pg_catalog.pg_get_userbyid(c.relowner) as "Owner",
c2.relname as "Table",
case
c.relpersistence
when 'p'
then 'permanent'
when 't'
then 'temporary'
when 'u'
then 'unlogged'
end as "Persistence",
am.amname as "Access method",
pg_catalog.pg_size_pretty(pg_catalog.pg_table_size(c.oid)) as "Size",
pg_catalog.obj_description(c.oid, 'pg_class') as "Description"
from pg_catalog.pg_class c
left join pg_catalog.pg_namespace n on n.oid = c.relnamespace
left join pg_catalog.pg_am am on am.oid = c.relam
left join pg_catalog.pg_index i on i.indexrelid = c.oid
left join pg_catalog.pg_class c2 on i.indrelid = c2.oid
where
c.relkind in ('i', 'I')
{where_clause}
order by 1,2;
""".strip("\n")
)
)
def show_describe_relation(
item: "RelationCatalogItem",
driver: "HarlequinDriver",
) -> None:
# sourced from psql -E \d+ {my rel}
# see https://stackoverflow.com/questions/60155968/using-results-of-d-command-in-psql
if item.parent is None:
driver.notify(
f"Could not describe {item.label} due to missing schema reference.",
severity="error",
)
return
driver.insert_text_in_new_buffer(
dedent(
f"""
with
index_columns as (
select i.indexrelid, c.oid as rel_oid, unnest(i.indkey) as attnum
from pg_catalog.pg_index i
join pg_catalog.pg_class c on c.oid = i.indrelid
where c.relname = '{item.label}'
),
index_column_counts as (
select rel_oid, attnum, count(*) as cnt
from index_columns
group by 1, 2
),
constraint_columns as (
select con.oid, c.oid as rel_oid, unnest(con.conkey) as attnum
from pg_catalog.pg_constraint con
join pg_catalog.pg_class c on con.conrelid = c.oid
where c.relname = '{item.label}'
),
constraint_column_counts as (
select rel_oid, attnum, count(*) as cnt
from constraint_columns
group by 1, 2
),
fkey_columns as (
select
src.relname as src_name,
src.relnamespace::regnamespace as src_schema,
c.oid as rel_oid,
unnest(con.confkey) as attnum
from pg_catalog.pg_constraint con
join pg_catalog.pg_class c on con.confrelid = c.oid
join pg_catalog.pg_class src on con.conrelid = src.oid
where c.relname = '{item.label}'
),
fkey_references as (
select
rel_oid,
attnum,
string_agg(src_schema || '.' || src_name, ', ') as sources
from fkey_columns
group by 1, 2
)
select
a.attname as "Column",
pg_catalog.format_type(a.atttypid, a.atttypmod) as "Type",
coll.collname as "Collation",
case
when a.attnotnull is true then 'not null'
else ''
end as "Nullable",
pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) as "Default",
case
a.attstorage
when 'p'
then 'plain'
when 'x'
then 'extended'
when 'e'
then 'external'
when 'm'
then 'main'
else a.attstorage::text
end as "Storage",
case
a.attcompression
when 'p'
then 'pglz'
when 'l'
then 'LZ4'
else a.attcompression::text
end as "Compression",
case
when a.attstattarget = -1
then null
else a.attstattarget
end as "Stats target",
case
when index_column_counts.cnt > 0 then true else false
end as "Has Index",
case
when constraint_column_counts.cnt > 0 then true else false
end as "Has Constraint",
fkey_references.sources as "Referenced by",
pg_catalog.col_description(a.attrelid, a.attnum) as "Description"
from pg_catalog.pg_attribute a
join pg_catalog.pg_class c on a.attrelid = c.oid
left join pg_catalog.pg_namespace n on n.oid = c.relnamespace
left join pg_catalog.pg_collation coll on coll.oid = a.attcollation
left join
pg_catalog.pg_type t
on (t.oid = a.atttypid and t.typcollation <> a.attcollation)
left join
pg_catalog.pg_attrdef d
on (a.attrelid = d.adrelid and a.attnum = d.adnum and a.atthasdef)
left join
index_column_counts
on a.attnum = index_column_counts.attnum
and a.attrelid = index_column_counts.rel_oid
left join
constraint_column_counts
on a.attnum = constraint_column_counts.attnum
and a.attrelid = constraint_column_counts.rel_oid
left join
fkey_references
on a.attnum = fkey_references.attnum
and a.attrelid = fkey_references.rel_oid
where
c.relname = '{item.label}'
and n.nspname = '{item.parent.label}'
and a.attnum > 0
and not a.attisdropped
order by a.attnum
""".strip("\n")
)
)
def show_describe_table_indexes(
item: "RelationCatalogItem",
driver: "HarlequinDriver",
) -> None:
if item.parent is None:
driver.notify(
f"Could not describe {item.label} due to missing schema reference.",
severity="error",
)
return
driver.insert_text_in_new_buffer(
dedent(
f"""
with
index_columns as (
select
i.indexrelid, c.oid as rel_oid, unnest(i.indkey) as attnum
from pg_catalog.pg_index i
join pg_catalog.pg_class c on c.oid = i.indrelid
where c.relname = '{item.label}'
),
index_column_names as (
select
index_columns.indexrelid,
string_agg(pg_attribute.attname, ', ') as columns
from index_columns
join
pg_catalog.pg_attribute
on index_columns.rel_oid = pg_attribute.attrelid
and index_columns.attnum = pg_attribute.attnum
group by 1
)
select
c.relname as "Table",
i.indexrelid::regclass::text as "Index Name",
pg_am.amname as "Index Type",
index_column_names.columns as "Columns",
i.indisprimary as "Is PK",
i.indisunique as "Is Unique",
i.indisclustered as "Is Clustered",
i.indisvalid as "Is Valid"
from pg_catalog.pg_index i
join pg_catalog.pg_class c on c.oid = i.indrelid
left join pg_catalog.pg_namespace n on n.oid = c.relnamespace
join pg_catalog.pg_class ic on i.indexrelid = ic.oid
join pg_catalog.pg_am on ic.relam = pg_am.oid
left join
index_column_names on i.indexrelid = index_column_names.indexrelid
where
c.relname = '{item.label}'
and n.nspname = '{item.parent.label}'
""".strip("\n")
)
)
def show_describe_table_constraints(
item: "RelationCatalogItem",
driver: "HarlequinDriver",
) -> None:
if item.parent is None:
driver.notify(
f"Could not describe {item.label} due to missing schema reference.",
severity="error",
)
return
driver.insert_text_in_new_buffer(
dedent(
f"""
with
constraint_columns as (
select con.oid, c.oid as rel_oid, unnest(con.conkey) as attnum
from pg_catalog.pg_constraint con
join pg_catalog.pg_class c on con.conrelid = c.oid
where c.relname = '{item.label}'
),
constraint_column_names as (
select
constraint_columns.oid,
string_agg(pg_attribute.attname, ', ') as columns
from constraint_columns
join
pg_catalog.pg_attribute
on constraint_columns.rel_oid = pg_attribute.attrelid
and constraint_columns.attnum = pg_attribute.attnum
group by 1
),
constraint_foreign_columns as (
select con.oid, c.oid as rel_oid, unnest(con.confkey) as attnum
from pg_catalog.pg_constraint con
join pg_catalog.pg_class c on con.conrelid = c.oid
where c.relname = '{item.label}'
),
constraint_foreign_column_names as (
select
constraint_foreign_columns.oid,
string_agg(pg_attribute.attname, ', ') as columns
from constraint_foreign_columns
join
pg_catalog.pg_attribute
on constraint_foreign_columns.rel_oid = pg_attribute.attrelid
and constraint_foreign_columns.attnum = pg_attribute.attnum
group by 1
)
select
c.relname as "Table",
con.conname as "Constraint name",
case
con.contype
when 'c'
then 'Check'
when 'n'
then 'Not Null'
when 'p'
then 'Primary Key'
when 'f'
then 'Foreign Key'
when 'u'
then 'Unique'
when 't'
then 'Trigger'
when 'x'
then 'Exclusion'
else con.contype::text
end as "Constraint Type",
constraint_column_names.columns as "Columns",
c.relnamespace::regnamespace
|| '.'
|| fc.relname
|| '('
|| constraint_foreign_column_names.columns
|| ')' as "References",
con.conislocal as "Is Local",
con.convalidated as "Is Validated",
case
con.confupdtype
when 'a'
then 'no action'
when 'r'
then 'restrict'
when 'c'
then 'cascade'
when 'n'
then 'set null'
when 'd'
then 'set default'
else con.confupdtype::text
end as "FK Update Type",
case
con.confdeltype
when 'a'
then 'no action'
when 'r'
then 'restrict'
when 'c'
then 'cascade'
when 'n'
then 'set null'
when 'd'
then 'set default'
else con.confdeltype::text
end as "FK Delete Type",
case
con.confmatchtype
when 's'
then 'simple'
when 'f'
then 'full'
when 'p'
then 'partial'
else con.confmatchtype::text
end as "FK Match Type"
from pg_catalog.pg_constraint con
join pg_catalog.pg_class c on con.conrelid = c.oid
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
left join pg_catalog.pg_class fc on con.confrelid = fc.oid
left join
constraint_column_names on con.oid = constraint_column_names.oid
left join
constraint_foreign_column_names
on con.oid = constraint_foreign_column_names.oid
where
c.relname = '{item.label}'
and n.nspname = '{item.parent.label}'
""".strip("\n")
)
)
def show_view_definition(
item: "ViewCatalogItem",
driver: "HarlequinDriver",
) -> None:
if item.connection is None or item.parent is None:
return
view_def_query = f"""
select pg_catalog.pg_get_viewdef(c.oid, true)
from pg_catalog.pg_class as c
left join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where c.relname = '{item.label}' and n.nspname = '{item.parent.label}'
""".strip("\n")
cur = item.connection.execute(view_def_query)
if cur is None:
return
result = cur.fetchall()
if result is None:
return
view_def: str = result[0][0]
driver.insert_text_in_new_buffer(
f"-- View definition for {item.query_name}\n" + view_def
)
def insert_columns_at_cursor(
item: "RelationCatalogItem",
driver: "HarlequinDriver",