@honua/react 0.0.20-alpha.0 → 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/contract/index.d.ts +2 -0
- package/contract/index.d.ts.map +1 -1
- package/contract/index.js +1 -0
- package/contract/pmtiles.d.ts +162 -0
- package/contract/pmtiles.d.ts.map +1 -0
- package/contract/pmtiles.js +155 -0
- package/contract/source.d.ts +24 -0
- package/contract/source.d.ts.map +1 -1
- package/contract/source.js +115 -8
- package/contract/types.d.ts +31 -2
- package/contract/types.d.ts.map +1 -1
- package/contract/types.js +8 -0
- package/core/auth/client-credentials-provider.d.ts +51 -0
- package/core/auth/client-credentials-provider.d.ts.map +1 -0
- package/core/auth/client-credentials-provider.js +115 -0
- package/core/auth/credential-store.d.ts +52 -0
- package/core/auth/credential-store.d.ts.map +1 -0
- package/core/auth/credential-store.js +82 -0
- package/core/auth/index.d.ts +23 -0
- package/core/auth/index.d.ts.map +1 -0
- package/core/auth/index.js +20 -0
- package/core/auth/oauth2-provider.d.ts +114 -0
- package/core/auth/oauth2-provider.d.ts.map +1 -0
- package/core/auth/oauth2-provider.js +408 -0
- package/core/auth/pkce.d.ts +25 -0
- package/core/auth/pkce.d.ts.map +1 -0
- package/core/auth/pkce.js +46 -0
- package/core/auth/static-providers.d.ts +20 -0
- package/core/auth/static-providers.d.ts.map +1 -0
- package/core/auth/static-providers.js +26 -0
- package/core/auth/token-endpoint.d.ts +40 -0
- package/core/auth/token-endpoint.d.ts.map +1 -0
- package/core/auth/token-endpoint.js +133 -0
- package/core/auth/types.d.ts +144 -0
- package/core/auth/types.d.ts.map +1 -0
- package/core/auth/types.js +8 -0
- package/core/client.d.ts +31 -1
- package/core/client.d.ts.map +1 -1
- package/core/client.js +52 -0
- package/core/errors.d.ts +33 -1
- package/core/errors.d.ts.map +1 -1
- package/core/errors.js +20 -0
- package/core/geoparquet-sql.d.ts +162 -0
- package/core/geoparquet-sql.d.ts.map +1 -0
- package/core/geoparquet-sql.js +333 -0
- package/core/ogc-endpoint-layout.d.ts +70 -0
- package/core/ogc-endpoint-layout.d.ts.map +1 -0
- package/core/ogc-endpoint-layout.js +173 -0
- package/core/ogc-features.d.ts.map +1 -1
- package/core/ogc-features.js +32 -17
- package/core/stac-static.d.ts +51 -0
- package/core/stac-static.d.ts.map +1 -0
- package/core/stac-static.js +195 -0
- package/core/stac.d.ts +12 -0
- package/core/stac.d.ts.map +1 -1
- package/core/stac.js +45 -15
- package/core/surfaces.d.ts +13 -0
- package/core/surfaces.d.ts.map +1 -1
- package/core/surfaces.js +58 -11
- package/core/types.d.ts +49 -0
- package/core/types.d.ts.map +1 -1
- package/core/wfs-capabilities.d.ts +10 -0
- package/core/wfs-capabilities.d.ts.map +1 -1
- package/core/wfs-capabilities.js +14 -2
- package/core/wfs.d.ts +13 -0
- package/core/wfs.d.ts.map +1 -1
- package/core/wfs.js +25 -5
- package/esri-compat/identity-manager.d.ts +43 -0
- package/esri-compat/identity-manager.d.ts.map +1 -1
- package/esri-compat/identity-manager.js +131 -1
- package/esri-compat/oauth-info.d.ts +15 -0
- package/esri-compat/oauth-info.d.ts.map +1 -1
- package/esri-compat/oauth-info.js +27 -0
- package/package.json +6 -1
- package/runtime/index.d.ts +2 -2
- package/runtime/index.d.ts.map +1 -1
- package/runtime/index.js +1 -4
- package/runtime/load-package.d.ts.map +1 -1
- package/runtime/load-package.js +8 -0
- package/runtime/map-package-validation.d.ts +0 -13
- package/runtime/map-package-validation.d.ts.map +1 -1
- package/runtime/map-package-validation.js +0 -12
- package/runtime/map-package.d.ts +1 -1
- package/runtime/map-package.d.ts.map +1 -1
- package/runtime/pmtiles-protocol.d.ts +77 -0
- package/runtime/pmtiles-protocol.d.ts.map +1 -0
- package/runtime/pmtiles-protocol.js +120 -0
- package/runtime/source-bridge.d.ts.map +1 -1
- package/runtime/source-bridge.js +27 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure SQL compiler for the GeoParquet / DuckDB-WASM `Source`
|
|
3
|
+
* (`@honua/sdk-js/geoparquet`). Translates the protocol-neutral
|
|
4
|
+
* {@link Query} envelope into DuckDB SQL over `read_parquet(...)` with spatial
|
|
5
|
+
* predicate pushdown.
|
|
6
|
+
*
|
|
7
|
+
* This module has **no runtime dependencies** (not even `@duckdb/duckdb-wasm`)
|
|
8
|
+
* so it can be unit- and snapshot-tested in isolation and so the SQL text is a
|
|
9
|
+
* pure function of its inputs. The heavy DuckDB orchestration lives in
|
|
10
|
+
* `src/geoparquet/`.
|
|
11
|
+
*
|
|
12
|
+
* ## SQL-injection safety
|
|
13
|
+
*
|
|
14
|
+
* Every value this compiler interpolates is either (a) a numeric literal that
|
|
15
|
+
* is validated to be a finite JS number and rendered with {@link numberLiteral},
|
|
16
|
+
* (b) a string literal escaped by {@link stringLiteral} (single quotes doubled,
|
|
17
|
+
* embedded NULs rejected), or (c) a SQL identifier escaped by
|
|
18
|
+
* {@link quoteIdentifier} (double quotes doubled, embedded NULs / control chars
|
|
19
|
+
* rejected). The one exception is {@link Query.where}: like the GeoServices and
|
|
20
|
+
* OGC adapters, `where` is caller-authored filter SQL and is passed through
|
|
21
|
+
* verbatim (wrapped in parentheses). Callers are responsible for the trust
|
|
22
|
+
* boundary on `where`, exactly as they are for a GeoServices `where` clause.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
import type { AggregationSpec, Query } from "../contract/types.js";
|
|
27
|
+
import type { SpatialFilter } from "./spatial-filter.js";
|
|
28
|
+
/**
|
|
29
|
+
* Quote a SQL identifier for DuckDB: wrap in double quotes and double any
|
|
30
|
+
* embedded double quote. Rejects NUL and other control characters, which
|
|
31
|
+
* cannot appear in a legitimate column name and are a classic smuggling
|
|
32
|
+
* vector. Overture column names (`id`, `names`, `geometry`, dotted struct
|
|
33
|
+
* paths accessed as whole columns) are all safe under this rule.
|
|
34
|
+
*/
|
|
35
|
+
export declare function quoteIdentifier(name: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Render a string SQL literal (single quotes doubled). Rejects embedded NUL.
|
|
38
|
+
*/
|
|
39
|
+
export declare function stringLiteral(value: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Render a numeric SQL literal, validating the input is a finite JS number.
|
|
42
|
+
* Non-finite values (NaN/Infinity) throw rather than emit `'NaN'` — a spatial
|
|
43
|
+
* predicate must never silently widen to an infinite envelope.
|
|
44
|
+
*/
|
|
45
|
+
export declare function numberLiteral(value: number): string;
|
|
46
|
+
/**
|
|
47
|
+
* Render an integer SQL literal (offset / limit). Coerces via `Math.trunc` and
|
|
48
|
+
* validates non-negative finiteness.
|
|
49
|
+
*/
|
|
50
|
+
export declare function integerLiteral(value: number): string;
|
|
51
|
+
/**
|
|
52
|
+
* Build the `read_parquet(...)` table expression from one or more file URLs /
|
|
53
|
+
* globs. A single source renders `read_parquet('url')`; multiple sources
|
|
54
|
+
* render `read_parquet(['a', 'b'])`. Every URL is escaped as a string literal.
|
|
55
|
+
* `union_by_name := true` and `hive_partitioning := true` are set so mixed
|
|
56
|
+
* schemas across a hive-partitioned Overture theme line up by column name.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parquetSourceExpr(urls: readonly string[]): string;
|
|
59
|
+
/**
|
|
60
|
+
* How the geometry column is physically stored, which decides the SQL used to
|
|
61
|
+
* (a) evaluate a spatial predicate and (b) project GeoJSON output.
|
|
62
|
+
*
|
|
63
|
+
* - `wkb`: GeoParquet 1.0/1.1 well-known-binary BLOB — wrap with
|
|
64
|
+
* `ST_GeomFromWKB(...)`.
|
|
65
|
+
* - `native`: Parquet-native `GEOMETRY` / `GEOGRAPHY` logical type (Parquet
|
|
66
|
+
* 2.11, Mar 2025) — DuckDB reads it as a `GEOMETRY`, used directly.
|
|
67
|
+
* - `geojson`: GeoParquet `GeoJSON` encoding (string) — wrap with
|
|
68
|
+
* `ST_GeomFromGeoJSON(...)`.
|
|
69
|
+
*/
|
|
70
|
+
export type GeometryEncoding = "wkb" | "native" | "geojson";
|
|
71
|
+
export interface GeometryColumnPlan {
|
|
72
|
+
/** Geometry column name in the parquet file. */
|
|
73
|
+
readonly column: string;
|
|
74
|
+
/** Physical encoding of that column. */
|
|
75
|
+
readonly encoding: GeometryEncoding;
|
|
76
|
+
/**
|
|
77
|
+
* Optional GeoParquet 1.1 "bbox covering" struct column (typically `bbox`
|
|
78
|
+
* with `xmin`/`ymin`/`xmax`/`ymax` fields). When present, an envelope filter
|
|
79
|
+
* can prune row groups without decoding any geometry.
|
|
80
|
+
*/
|
|
81
|
+
readonly bboxColumn?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* SQL expression that yields a DuckDB `GEOMETRY` from the stored geometry
|
|
85
|
+
* column, adapting to the physical encoding.
|
|
86
|
+
*/
|
|
87
|
+
export declare function geometryExpr(plan: GeometryColumnPlan): string;
|
|
88
|
+
export interface BBox {
|
|
89
|
+
readonly xmin: number;
|
|
90
|
+
readonly ymin: number;
|
|
91
|
+
readonly xmax: number;
|
|
92
|
+
readonly ymax: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Reduce any Esri-shaped {@link SpatialFilter} geometry to its axis-aligned
|
|
96
|
+
* bounding box. Envelopes are exact; points, polylines (`paths`), polygons
|
|
97
|
+
* (`rings`), and multipoints (`points`) are reduced to their coordinate
|
|
98
|
+
* bounds. Returns `undefined` when the geometry carries no usable coordinates.
|
|
99
|
+
*/
|
|
100
|
+
export declare function bboxFromSpatialFilter(filter: SpatialFilter): BBox | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* True when the spatial filter is a plain envelope (exact bbox intersection).
|
|
103
|
+
* Anything else is reduced to its bbox and is therefore an approximation the
|
|
104
|
+
* caller should be told about via a `degraded` reason.
|
|
105
|
+
*/
|
|
106
|
+
export declare function isEnvelopeFilter(filter: SpatialFilter): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Compile the spatial predicate for a bbox against a geometry plan. Prefers the
|
|
109
|
+
* GeoParquet 1.1 bbox-covering column (row-group-skippable) when present, then
|
|
110
|
+
* falls back to `ST_Intersects(<geom>, ST_MakeEnvelope(...))`.
|
|
111
|
+
*/
|
|
112
|
+
export declare function spatialPredicate(bbox: BBox, plan: GeometryColumnPlan): string;
|
|
113
|
+
export interface CompileOptions {
|
|
114
|
+
/** Parquet file URL(s) / glob(s). */
|
|
115
|
+
readonly sources: readonly string[];
|
|
116
|
+
/** Geometry column plan (from metadata detection), if the file is spatial. */
|
|
117
|
+
readonly geometry?: GeometryColumnPlan;
|
|
118
|
+
/**
|
|
119
|
+
* All non-geometry column names available in the file, used to expand a
|
|
120
|
+
* `returnGeometry`-only projection and to validate that requested
|
|
121
|
+
* `outFields` are addressable. When omitted, `*` (minus geometry) is used.
|
|
122
|
+
*/
|
|
123
|
+
readonly columns?: readonly string[];
|
|
124
|
+
/** Output alias for the projected GeoJSON geometry. Defaults to `geometry`. */
|
|
125
|
+
readonly geometryAlias?: string;
|
|
126
|
+
}
|
|
127
|
+
export interface CompiledSql {
|
|
128
|
+
readonly sql: string;
|
|
129
|
+
/** True when a non-envelope spatial filter was reduced to its bbox. */
|
|
130
|
+
readonly bboxApproximated: boolean;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Compile a feature {@link Query} into a DuckDB `SELECT`. The geometry column,
|
|
134
|
+
* when present and `returnGeometry !== false`, is projected as GeoJSON text via
|
|
135
|
+
* `ST_AsGeoJSON` under the alias `geometry` (overridable).
|
|
136
|
+
*/
|
|
137
|
+
export declare function compileQuery(query: Query, options: CompileOptions): CompiledSql;
|
|
138
|
+
/**
|
|
139
|
+
* Compile an {@link AggregationSpec} into a GROUP BY `SELECT`. Group-by fields
|
|
140
|
+
* are echoed in the projection (so the returned rows carry the group key),
|
|
141
|
+
* followed by each metric expression aliased to its `alias` (or `fn_field`).
|
|
142
|
+
*/
|
|
143
|
+
export declare function compileAggregate(query: Query & {
|
|
144
|
+
aggregation: AggregationSpec;
|
|
145
|
+
}, options: CompileOptions): CompiledSql;
|
|
146
|
+
/**
|
|
147
|
+
* SQL that reads the parquet footer schema without scanning rows.
|
|
148
|
+
*/
|
|
149
|
+
export declare function describeSql(sources: readonly string[]): string;
|
|
150
|
+
/**
|
|
151
|
+
* Cheap row-count estimate from the parquet footer (`num_rows` per row group),
|
|
152
|
+
* summed across all files. Avoids a full `count(*)` table scan on large
|
|
153
|
+
* Overture extracts.
|
|
154
|
+
*/
|
|
155
|
+
export declare function rowEstimateSql(sources: readonly string[]): string;
|
|
156
|
+
/**
|
|
157
|
+
* SQL that reads the GeoParquet `geo` key-value metadata (the JSON blob that
|
|
158
|
+
* carries `primary_column`, per-column `encoding`, and `crs`). Returns zero
|
|
159
|
+
* rows for Parquet-native geometry files, which carry no `geo` metadata.
|
|
160
|
+
*/
|
|
161
|
+
export declare function geoMetadataSql(sources: readonly string[]): string;
|
|
162
|
+
//# sourceMappingURL=geoparquet-sql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"geoparquet-sql.d.ts","sourceRoot":"","sources":["../../../src/core/geoparquet-sql.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure SQL compiler for the GeoParquet / DuckDB-WASM `Source`
|
|
3
|
+
* (`@honua/sdk-js/geoparquet`). Translates the protocol-neutral
|
|
4
|
+
* {@link Query} envelope into DuckDB SQL over `read_parquet(...)` with spatial
|
|
5
|
+
* predicate pushdown.
|
|
6
|
+
*
|
|
7
|
+
* This module has **no runtime dependencies** (not even `@duckdb/duckdb-wasm`)
|
|
8
|
+
* so it can be unit- and snapshot-tested in isolation and so the SQL text is a
|
|
9
|
+
* pure function of its inputs. The heavy DuckDB orchestration lives in
|
|
10
|
+
* `src/geoparquet/`.
|
|
11
|
+
*
|
|
12
|
+
* ## SQL-injection safety
|
|
13
|
+
*
|
|
14
|
+
* Every value this compiler interpolates is either (a) a numeric literal that
|
|
15
|
+
* is validated to be a finite JS number and rendered with {@link numberLiteral},
|
|
16
|
+
* (b) a string literal escaped by {@link stringLiteral} (single quotes doubled,
|
|
17
|
+
* embedded NULs rejected), or (c) a SQL identifier escaped by
|
|
18
|
+
* {@link quoteIdentifier} (double quotes doubled, embedded NULs / control chars
|
|
19
|
+
* rejected). The one exception is {@link Query.where}: like the GeoServices and
|
|
20
|
+
* OGC adapters, `where` is caller-authored filter SQL and is passed through
|
|
21
|
+
* verbatim (wrapped in parentheses). Callers are responsible for the trust
|
|
22
|
+
* boundary on `where`, exactly as they are for a GeoServices `where` clause.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
// ── Identifier / literal escaping ─────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* Quote a SQL identifier for DuckDB: wrap in double quotes and double any
|
|
29
|
+
* embedded double quote. Rejects NUL and other control characters, which
|
|
30
|
+
* cannot appear in a legitimate column name and are a classic smuggling
|
|
31
|
+
* vector. Overture column names (`id`, `names`, `geometry`, dotted struct
|
|
32
|
+
* paths accessed as whole columns) are all safe under this rule.
|
|
33
|
+
*/
|
|
34
|
+
export function quoteIdentifier(name) {
|
|
35
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
36
|
+
throw new Error(`geoparquet: invalid SQL identifier ${JSON.stringify(name)}`);
|
|
37
|
+
}
|
|
38
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control chars in identifiers is the point
|
|
39
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) {
|
|
40
|
+
throw new Error(`geoparquet: SQL identifier contains control characters: ${JSON.stringify(name)}`);
|
|
41
|
+
}
|
|
42
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Render a string SQL literal (single quotes doubled). Rejects embedded NUL.
|
|
46
|
+
*/
|
|
47
|
+
export function stringLiteral(value) {
|
|
48
|
+
if (typeof value !== "string") {
|
|
49
|
+
throw new Error(`geoparquet: expected a string literal, received ${typeof value}`);
|
|
50
|
+
}
|
|
51
|
+
if (value.includes("\u0000")) {
|
|
52
|
+
throw new Error("geoparquet: string literal contains a NUL byte");
|
|
53
|
+
}
|
|
54
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Render a numeric SQL literal, validating the input is a finite JS number.
|
|
58
|
+
* Non-finite values (NaN/Infinity) throw rather than emit `'NaN'` — a spatial
|
|
59
|
+
* predicate must never silently widen to an infinite envelope.
|
|
60
|
+
*/
|
|
61
|
+
export function numberLiteral(value) {
|
|
62
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
63
|
+
throw new Error(`geoparquet: expected a finite number, received ${String(value)}`);
|
|
64
|
+
}
|
|
65
|
+
return String(value);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Render an integer SQL literal (offset / limit). Coerces via `Math.trunc` and
|
|
69
|
+
* validates non-negative finiteness.
|
|
70
|
+
*/
|
|
71
|
+
export function integerLiteral(value) {
|
|
72
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
73
|
+
throw new Error(`geoparquet: expected a non-negative integer, received ${String(value)}`);
|
|
74
|
+
}
|
|
75
|
+
return String(Math.trunc(value));
|
|
76
|
+
}
|
|
77
|
+
// ── read_parquet(...) source expression ───────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Build the `read_parquet(...)` table expression from one or more file URLs /
|
|
80
|
+
* globs. A single source renders `read_parquet('url')`; multiple sources
|
|
81
|
+
* render `read_parquet(['a', 'b'])`. Every URL is escaped as a string literal.
|
|
82
|
+
* `union_by_name := true` and `hive_partitioning := true` are set so mixed
|
|
83
|
+
* schemas across a hive-partitioned Overture theme line up by column name.
|
|
84
|
+
*/
|
|
85
|
+
export function parquetSourceExpr(urls) {
|
|
86
|
+
if (!Array.isArray(urls) || urls.length === 0) {
|
|
87
|
+
throw new Error("geoparquet: at least one parquet URL is required");
|
|
88
|
+
}
|
|
89
|
+
const list = urls.map(stringLiteral);
|
|
90
|
+
const arg = list.length === 1 ? list[0] : `[${list.join(", ")}]`;
|
|
91
|
+
return `read_parquet(${arg}, union_by_name = true, hive_partitioning = true)`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* SQL expression that yields a DuckDB `GEOMETRY` from the stored geometry
|
|
95
|
+
* column, adapting to the physical encoding.
|
|
96
|
+
*/
|
|
97
|
+
export function geometryExpr(plan) {
|
|
98
|
+
const col = quoteIdentifier(plan.column);
|
|
99
|
+
switch (plan.encoding) {
|
|
100
|
+
case "wkb":
|
|
101
|
+
return `ST_GeomFromWKB(${col})`;
|
|
102
|
+
case "geojson":
|
|
103
|
+
return `ST_GeomFromGeoJSON(${col})`;
|
|
104
|
+
case "native":
|
|
105
|
+
return col;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Reduce any Esri-shaped {@link SpatialFilter} geometry to its axis-aligned
|
|
110
|
+
* bounding box. Envelopes are exact; points, polylines (`paths`), polygons
|
|
111
|
+
* (`rings`), and multipoints (`points`) are reduced to their coordinate
|
|
112
|
+
* bounds. Returns `undefined` when the geometry carries no usable coordinates.
|
|
113
|
+
*/
|
|
114
|
+
export function bboxFromSpatialFilter(filter) {
|
|
115
|
+
const g = filter.geometry;
|
|
116
|
+
if (typeof g.xmin === "number" &&
|
|
117
|
+
typeof g.ymin === "number" &&
|
|
118
|
+
typeof g.xmax === "number" &&
|
|
119
|
+
typeof g.ymax === "number") {
|
|
120
|
+
return { xmin: g.xmin, ymin: g.ymin, xmax: g.xmax, ymax: g.ymax };
|
|
121
|
+
}
|
|
122
|
+
if (typeof g.x === "number" && typeof g.y === "number") {
|
|
123
|
+
return { xmin: g.x, ymin: g.y, xmax: g.x, ymax: g.y };
|
|
124
|
+
}
|
|
125
|
+
const coordSets = [];
|
|
126
|
+
if (Array.isArray(g.rings))
|
|
127
|
+
coordSets.push(...g.rings);
|
|
128
|
+
if (Array.isArray(g.paths))
|
|
129
|
+
coordSets.push(...g.paths);
|
|
130
|
+
if (Array.isArray(g.points))
|
|
131
|
+
coordSets.push(g.points);
|
|
132
|
+
let xmin = Number.POSITIVE_INFINITY;
|
|
133
|
+
let ymin = Number.POSITIVE_INFINITY;
|
|
134
|
+
let xmax = Number.NEGATIVE_INFINITY;
|
|
135
|
+
let ymax = Number.NEGATIVE_INFINITY;
|
|
136
|
+
let seen = false;
|
|
137
|
+
for (const set of coordSets) {
|
|
138
|
+
if (!Array.isArray(set))
|
|
139
|
+
continue;
|
|
140
|
+
for (const pt of set) {
|
|
141
|
+
if (!Array.isArray(pt) || typeof pt[0] !== "number" || typeof pt[1] !== "number")
|
|
142
|
+
continue;
|
|
143
|
+
seen = true;
|
|
144
|
+
xmin = Math.min(xmin, pt[0]);
|
|
145
|
+
ymin = Math.min(ymin, pt[1]);
|
|
146
|
+
xmax = Math.max(xmax, pt[0]);
|
|
147
|
+
ymax = Math.max(ymax, pt[1]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return seen ? { xmin, ymin, xmax, ymax } : undefined;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* True when the spatial filter is a plain envelope (exact bbox intersection).
|
|
154
|
+
* Anything else is reduced to its bbox and is therefore an approximation the
|
|
155
|
+
* caller should be told about via a `degraded` reason.
|
|
156
|
+
*/
|
|
157
|
+
export function isEnvelopeFilter(filter) {
|
|
158
|
+
return filter.geometryType === "esriGeometryEnvelope";
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Compile the spatial predicate for a bbox against a geometry plan. Prefers the
|
|
162
|
+
* GeoParquet 1.1 bbox-covering column (row-group-skippable) when present, then
|
|
163
|
+
* falls back to `ST_Intersects(<geom>, ST_MakeEnvelope(...))`.
|
|
164
|
+
*/
|
|
165
|
+
export function spatialPredicate(bbox, plan) {
|
|
166
|
+
const xmin = numberLiteral(bbox.xmin);
|
|
167
|
+
const ymin = numberLiteral(bbox.ymin);
|
|
168
|
+
const xmax = numberLiteral(bbox.xmax);
|
|
169
|
+
const ymax = numberLiteral(bbox.ymax);
|
|
170
|
+
if (plan.bboxColumn) {
|
|
171
|
+
const b = quoteIdentifier(plan.bboxColumn);
|
|
172
|
+
// Two envelopes intersect iff neither is strictly to one side of the other.
|
|
173
|
+
return `${b}.xmin <= ${xmax} AND ${b}.xmax >= ${xmin} AND ` + `${b}.ymin <= ${ymax} AND ${b}.ymax >= ${ymin}`;
|
|
174
|
+
}
|
|
175
|
+
return `ST_Intersects(${geometryExpr(plan)}, ST_MakeEnvelope(${xmin}, ${ymin}, ${xmax}, ${ymax}))`;
|
|
176
|
+
}
|
|
177
|
+
const DEFAULT_GEOMETRY_ALIAS = "geometry";
|
|
178
|
+
// ── WHERE + ORDER BY + LIMIT fragments ────────────────────────
|
|
179
|
+
function whereClause(query, spatial) {
|
|
180
|
+
const clauses = [];
|
|
181
|
+
if (typeof query.where === "string" && query.where.trim().length > 0) {
|
|
182
|
+
clauses.push(`(${query.where})`);
|
|
183
|
+
}
|
|
184
|
+
if (spatial)
|
|
185
|
+
clauses.push(`(${spatial})`);
|
|
186
|
+
return clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
187
|
+
}
|
|
188
|
+
function orderByClause(orderBy) {
|
|
189
|
+
if (!orderBy || orderBy.length === 0)
|
|
190
|
+
return "";
|
|
191
|
+
const parts = orderBy.map((s) => {
|
|
192
|
+
const dir = s.direction === "desc" ? " DESC" : " ASC";
|
|
193
|
+
return `${quoteIdentifier(s.field)}${dir}`;
|
|
194
|
+
});
|
|
195
|
+
return ` ORDER BY ${parts.join(", ")}`;
|
|
196
|
+
}
|
|
197
|
+
function limitOffsetClause(query) {
|
|
198
|
+
let out = "";
|
|
199
|
+
const limit = query.pagination?.limit;
|
|
200
|
+
const offset = query.pagination?.offset;
|
|
201
|
+
if (typeof limit === "number")
|
|
202
|
+
out += ` LIMIT ${integerLiteral(limit)}`;
|
|
203
|
+
if (typeof offset === "number" && offset > 0)
|
|
204
|
+
out += ` OFFSET ${integerLiteral(offset)}`;
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
// ── Projection ────────────────────────────────────────────────
|
|
208
|
+
function projection(query, options) {
|
|
209
|
+
const geom = options.geometry;
|
|
210
|
+
const geomAlias = quoteIdentifier(options.geometryAlias ?? DEFAULT_GEOMETRY_ALIAS);
|
|
211
|
+
const wantGeometry = query.returnGeometry !== false && geom !== undefined;
|
|
212
|
+
const geomSelect = wantGeometry ? `ST_AsGeoJSON(${geometryExpr(geom)}) AS ${geomAlias}` : undefined;
|
|
213
|
+
const outFields = query.outFields;
|
|
214
|
+
if (outFields && outFields.length > 0) {
|
|
215
|
+
const cols = outFields.filter((f) => !geom || f !== geom.column).map((f) => quoteIdentifier(f));
|
|
216
|
+
if (geomSelect)
|
|
217
|
+
cols.push(geomSelect);
|
|
218
|
+
return cols.length > 0 ? cols.join(", ") : "*";
|
|
219
|
+
}
|
|
220
|
+
// No explicit projection: select every non-geometry column, plus the
|
|
221
|
+
// GeoJSON-projected geometry when requested. When we do not know the column
|
|
222
|
+
// list, fall back to `* EXCLUDE (geom)` so raw WKB never rides along.
|
|
223
|
+
const nonGeom = options.columns?.filter((c) => !geom || c !== geom.column);
|
|
224
|
+
if (nonGeom && nonGeom.length > 0) {
|
|
225
|
+
const cols = nonGeom.map((c) => quoteIdentifier(c));
|
|
226
|
+
if (geomSelect)
|
|
227
|
+
cols.push(geomSelect);
|
|
228
|
+
return cols.join(", ");
|
|
229
|
+
}
|
|
230
|
+
if (geom) {
|
|
231
|
+
const base = `* EXCLUDE (${quoteIdentifier(geom.column)})`;
|
|
232
|
+
return geomSelect ? `${base}, ${geomSelect}` : base;
|
|
233
|
+
}
|
|
234
|
+
return "*";
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Compile a feature {@link Query} into a DuckDB `SELECT`. The geometry column,
|
|
238
|
+
* when present and `returnGeometry !== false`, is projected as GeoJSON text via
|
|
239
|
+
* `ST_AsGeoJSON` under the alias `geometry` (overridable).
|
|
240
|
+
*/
|
|
241
|
+
export function compileQuery(query, options) {
|
|
242
|
+
const from = parquetSourceExpr(options.sources);
|
|
243
|
+
let spatial;
|
|
244
|
+
let bboxApproximated = false;
|
|
245
|
+
if (query.spatialFilter) {
|
|
246
|
+
if (!options.geometry) {
|
|
247
|
+
throw new Error("geoparquet: spatialFilter supplied but the source has no detected geometry column");
|
|
248
|
+
}
|
|
249
|
+
const bbox = bboxFromSpatialFilter(query.spatialFilter);
|
|
250
|
+
if (bbox) {
|
|
251
|
+
spatial = spatialPredicate(bbox, options.geometry);
|
|
252
|
+
bboxApproximated = !isEnvelopeFilter(query.spatialFilter);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const sql = `SELECT ${projection(query, options)} FROM ${from}${whereClause(query, spatial)}${orderByClause(query.orderBy)}${limitOffsetClause(query)}`;
|
|
256
|
+
return { sql, bboxApproximated };
|
|
257
|
+
}
|
|
258
|
+
function aggregateFn(fn, field) {
|
|
259
|
+
const col = field === "*" ? "*" : quoteIdentifier(field);
|
|
260
|
+
switch (fn) {
|
|
261
|
+
case "count":
|
|
262
|
+
return `count(${col})`;
|
|
263
|
+
case "sum":
|
|
264
|
+
return `sum(${col})`;
|
|
265
|
+
case "avg":
|
|
266
|
+
return `avg(${col})`;
|
|
267
|
+
case "min":
|
|
268
|
+
return `min(${col})`;
|
|
269
|
+
case "max":
|
|
270
|
+
return `max(${col})`;
|
|
271
|
+
case "stddev":
|
|
272
|
+
return `stddev_samp(${col})`;
|
|
273
|
+
case "var":
|
|
274
|
+
return `var_samp(${col})`;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Compile an {@link AggregationSpec} into a GROUP BY `SELECT`. Group-by fields
|
|
279
|
+
* are echoed in the projection (so the returned rows carry the group key),
|
|
280
|
+
* followed by each metric expression aliased to its `alias` (or `fn_field`).
|
|
281
|
+
*/
|
|
282
|
+
export function compileAggregate(query, options) {
|
|
283
|
+
const from = parquetSourceExpr(options.sources);
|
|
284
|
+
const agg = query.aggregation;
|
|
285
|
+
if (!agg.metrics || agg.metrics.length === 0) {
|
|
286
|
+
throw new Error("geoparquet: aggregation requires at least one metric");
|
|
287
|
+
}
|
|
288
|
+
const groupBy = agg.groupBy ?? [];
|
|
289
|
+
const selectParts = [];
|
|
290
|
+
for (const field of groupBy)
|
|
291
|
+
selectParts.push(quoteIdentifier(field));
|
|
292
|
+
for (const m of agg.metrics) {
|
|
293
|
+
const alias = quoteIdentifier(m.alias ?? `${m.fn}_${m.field === "*" ? "all" : m.field}`);
|
|
294
|
+
selectParts.push(`${aggregateFn(m.fn, m.field)} AS ${alias}`);
|
|
295
|
+
}
|
|
296
|
+
let spatial;
|
|
297
|
+
let bboxApproximated = false;
|
|
298
|
+
if (query.spatialFilter && options.geometry) {
|
|
299
|
+
const bbox = bboxFromSpatialFilter(query.spatialFilter);
|
|
300
|
+
if (bbox) {
|
|
301
|
+
spatial = spatialPredicate(bbox, options.geometry);
|
|
302
|
+
bboxApproximated = !isEnvelopeFilter(query.spatialFilter);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const groupByClause = groupBy.length > 0 ? ` GROUP BY ${groupBy.map((f) => quoteIdentifier(f)).join(", ")}` : "";
|
|
306
|
+
const sql = `SELECT ${selectParts.join(", ")} FROM ${from}${whereClause(query, spatial)}${groupByClause}${orderByClause(query.orderBy)}${limitOffsetClause(query)}`;
|
|
307
|
+
return { sql, bboxApproximated };
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* SQL that reads the parquet footer schema without scanning rows.
|
|
311
|
+
*/
|
|
312
|
+
export function describeSql(sources) {
|
|
313
|
+
return `DESCRIBE SELECT * FROM ${parquetSourceExpr(sources)}`;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Cheap row-count estimate from the parquet footer (`num_rows` per row group),
|
|
317
|
+
* summed across all files. Avoids a full `count(*)` table scan on large
|
|
318
|
+
* Overture extracts.
|
|
319
|
+
*/
|
|
320
|
+
export function rowEstimateSql(sources) {
|
|
321
|
+
const list = sources.map(stringLiteral);
|
|
322
|
+
const arg = list.length === 1 ? list[0] : `[${list.join(", ")}]`;
|
|
323
|
+
return `SELECT CAST(COALESCE(sum(num_rows), 0) AS BIGINT) AS row_estimate FROM parquet_file_metadata(${arg})`;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* SQL that reads the GeoParquet `geo` key-value metadata (the JSON blob that
|
|
327
|
+
* carries `primary_column`, per-column `encoding`, and `crs`). Returns zero
|
|
328
|
+
* rows for Parquet-native geometry files, which carry no `geo` metadata.
|
|
329
|
+
*/
|
|
330
|
+
export function geoMetadataSql(sources) {
|
|
331
|
+
const first = stringLiteral(sources[0] ?? "");
|
|
332
|
+
return `SELECT value FROM parquet_kv_metadata(${first}) WHERE decode(key) = 'geo'`;
|
|
333
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OGC API endpoint-layout resolution.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's headline claim is one typed contract against ANY standards
|
|
5
|
+
* server. For OGC API Features / Records that means the client must not
|
|
6
|
+
* assume a fixed path prefix: a Honua Server mounts the facade at
|
|
7
|
+
* `/ogc/features/...`, but pygeoapi, ldproxy, and GeoServer's OGC API each
|
|
8
|
+
* serve the landing page at their own root and advertise their collections
|
|
9
|
+
* and conformance URLs through the landing page's `links` array
|
|
10
|
+
* (OGC API - Common Requirement 5).
|
|
11
|
+
*
|
|
12
|
+
* This module produces an {@link OgcEndpointLayout} — the concrete set of
|
|
13
|
+
* resource paths a Features client addresses:
|
|
14
|
+
*
|
|
15
|
+
* - {@link honuaFacadeFeaturesLayout} is the fixed facade layout and the
|
|
16
|
+
* default. It performs no network round-trips, so existing callers see
|
|
17
|
+
* zero behaviour change.
|
|
18
|
+
* - {@link resolveOgcEndpointLayout} discovers the layout from the landing
|
|
19
|
+
* page (`ogc-api` mode) or auto-detects the facade first and falls back
|
|
20
|
+
* to discovery (`auto` mode).
|
|
21
|
+
*
|
|
22
|
+
* Per OGC API - Features Requirement 17 the items resource is always at
|
|
23
|
+
* `{collections}/{collectionId}/items`, so a single landing-page fetch is
|
|
24
|
+
* enough to derive every per-collection path from the discovered
|
|
25
|
+
* collections URL.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
*/
|
|
29
|
+
import type { HonuaProtocolTransport } from "./protocol-transport.js";
|
|
30
|
+
import type { OgcEndpointLayout } from "./types.js";
|
|
31
|
+
/** Discovery mode carried on a source locator. */
|
|
32
|
+
export type OgcApiLayoutMode = "honua-facade" | "ogc-api" | "auto";
|
|
33
|
+
/**
|
|
34
|
+
* The Honua Server facade layout (`/ogc/features/...`). No network access;
|
|
35
|
+
* this is the default fast path and preserves the pre-existing behaviour of
|
|
36
|
+
* every OGC Features caller that points at a Honua Server.
|
|
37
|
+
*/
|
|
38
|
+
export declare function honuaFacadeFeaturesLayout(base?: string): OgcEndpointLayout;
|
|
39
|
+
/**
|
|
40
|
+
* A raw OGC API layout built from a discovered (or configured) landing +
|
|
41
|
+
* collections + conformance URL triple. Per-collection paths are templated
|
|
42
|
+
* off the collections URL per OGC API - Features Requirement 17.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ogcApiFeaturesLayout(options: {
|
|
45
|
+
landingUrl: string;
|
|
46
|
+
collectionsUrl: string;
|
|
47
|
+
conformanceUrl: string;
|
|
48
|
+
}): OgcEndpointLayout;
|
|
49
|
+
/**
|
|
50
|
+
* Find the first link whose `rel` matches one of `rels` (exact or as the
|
|
51
|
+
* final path segment of a full OGC relation URI, e.g.
|
|
52
|
+
* `http://www.opengis.net/def/rel/ogc/1.0/data`).
|
|
53
|
+
*/
|
|
54
|
+
export declare function findOgcLink(links: ReadonlyArray<{
|
|
55
|
+
href?: string;
|
|
56
|
+
rel?: string;
|
|
57
|
+
}> | undefined, ...rels: string[]): string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the endpoint layout for the requested `mode`:
|
|
60
|
+
*
|
|
61
|
+
* - `honua-facade` (default) — the fixed facade layout, no round-trips.
|
|
62
|
+
* - `ogc-api` — discover from the landing page at the client baseUrl.
|
|
63
|
+
* - `auto` — probe the facade landing; use it when valid, otherwise
|
|
64
|
+
* discover from the root.
|
|
65
|
+
*
|
|
66
|
+
* Discovery failures surface as {@link HonuaCapabilityNotSupportedError}
|
|
67
|
+
* (per the SDK convention of throwing rather than returning empty data).
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveOgcEndpointLayout(transport: HonuaProtocolTransport, mode?: OgcApiLayoutMode): Promise<OgcEndpointLayout>;
|
|
70
|
+
//# sourceMappingURL=ogc-endpoint-layout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ogc-endpoint-layout.d.ts","sourceRoot":"","sources":["../../../src/core/ogc-endpoint-layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,KAAK,EAA2B,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE7E,kDAAkD;AAClD,MAAM,MAAM,gBAAgB,GAAG,cAAc,GAAG,SAAS,GAAG,MAAM,CAAC;AAcnE;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,GAAE,MAA4B,GAAG,iBAAiB,CAY/F;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB,GAAG,iBAAiB,CAiBpB;AAgBD;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,aAAa,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,SAAS,EACjE,GAAG,IAAI,EAAE,MAAM,EAAE,GAChB,MAAM,GAAG,SAAS,CAWpB;AAyCD;;;;;;;;;;GAUG;AACH,wBAAsB,wBAAwB,CAC5C,SAAS,EAAE,sBAAsB,EACjC,IAAI,GAAE,gBAAiC,GACtC,OAAO,CAAC,iBAAiB,CAAC,CAe5B"}
|