@honua/react 0.1.2-beta.0 → 0.1.7-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/discovery.d.ts +34 -0
- package/contract/discovery.d.ts.map +1 -1
- package/contract/index.d.ts +7 -5
- package/contract/index.d.ts.map +1 -1
- package/contract/index.js +3 -2
- package/contract/pmtiles.d.ts +10 -1
- package/contract/pmtiles.d.ts.map +1 -1
- package/contract/pmtiles.js +10 -2
- package/contract/protocol-module.d.ts +84 -9
- package/contract/protocol-module.d.ts.map +1 -1
- package/contract/query-filter-fes.d.ts +18 -0
- package/contract/query-filter-fes.d.ts.map +1 -0
- package/contract/query-filter-fes.js +122 -0
- package/contract/query-filter.d.ts +300 -0
- package/contract/query-filter.d.ts.map +1 -0
- package/contract/query-filter.js +883 -0
- package/contract/schema.d.ts +12 -0
- package/contract/schema.d.ts.map +1 -1
- package/contract/schema.js +34 -0
- package/contract/source-capability-support.d.ts.map +1 -1
- package/contract/source-capability-support.js +15 -3
- package/contract/source.d.ts +45 -25
- package/contract/source.d.ts.map +1 -1
- package/contract/source.js +719 -479
- package/contract/types.d.ts +88 -19
- package/contract/types.d.ts.map +1 -1
- package/contract/types.js +4 -0
- package/contract/widget-source.d.ts.map +1 -1
- package/core/client.d.ts +51 -21
- package/core/client.d.ts.map +1 -1
- package/core/client.js +234 -54
- package/core/error-classifications.d.ts +23 -0
- package/core/error-classifications.d.ts.map +1 -1
- package/core/error-classifications.js +23 -0
- package/core/error-code-registry.d.ts +24 -1
- package/core/error-code-registry.d.ts.map +1 -1
- package/core/error-code-registry.js +23 -0
- package/core/error-envelope.js +2 -0
- package/core/errors.d.ts +2 -2
- package/core/errors.d.ts.map +1 -1
- package/core/errors.js +1 -1
- package/core/geoparquet-sql.d.ts +69 -5
- package/core/geoparquet-sql.d.ts.map +1 -1
- package/core/geoparquet-sql.js +327 -10
- package/core/odata-write-codec.d.ts.map +1 -1
- package/core/odata-write-codec.js +0 -1
- package/core/odata.d.ts +6 -0
- package/core/odata.d.ts.map +1 -1
- package/core/odata.js +6 -1
- package/core/ogc-processes.d.ts +182 -25
- package/core/ogc-processes.d.ts.map +1 -1
- package/core/ogc-processes.js +602 -30
- package/core/process-runner.d.ts +8 -1
- package/core/process-runner.d.ts.map +1 -1
- package/core/process-runner.js +1 -0
- package/core/protocol-transport.d.ts +2 -0
- package/core/protocol-transport.d.ts.map +1 -1
- package/core/stac.d.ts +32 -0
- package/core/stac.d.ts.map +1 -1
- package/core/stac.js +158 -23
- package/core/surfaces.d.ts.map +1 -1
- package/core/surfaces.js +273 -68
- package/core/types.d.ts +138 -11
- package/core/types.d.ts.map +1 -1
- package/core/wfs-axis-order.d.ts +18 -0
- package/core/wfs-axis-order.d.ts.map +1 -0
- package/core/wfs-axis-order.js +108 -0
- package/core/wfs-capabilities.d.ts +65 -24
- package/core/wfs-capabilities.d.ts.map +1 -1
- package/core/wfs-capabilities.js +237 -27
- package/core/wfs-filter.d.ts +26 -3
- package/core/wfs-filter.d.ts.map +1 -1
- package/core/wfs-filter.js +48 -53
- package/core/wfs-protocol-error.d.ts +12 -0
- package/core/wfs-protocol-error.d.ts.map +1 -0
- package/core/wfs-protocol-error.js +13 -0
- package/core/wfs.d.ts +53 -1
- package/core/wfs.d.ts.map +1 -1
- package/core/wfs.js +251 -29
- package/core/wms-feature-info.d.ts +92 -0
- package/core/wms-feature-info.d.ts.map +1 -0
- package/core/wms-feature-info.js +285 -0
- package/core/wms.d.ts +9 -0
- package/core/wms.d.ts.map +1 -1
- package/core/wms.js +17 -5
- package/esri-compat/event-bus.d.ts +20 -0
- package/esri-compat/event-bus.d.ts.map +1 -1
- package/esri-compat/feature-filter.d.ts +46 -1
- package/esri-compat/feature-filter.d.ts.map +1 -1
- package/esri-compat/feature-filter.js +49 -0
- package/esri-compat/geojson-layer.d.ts +4 -2
- package/esri-compat/geojson-layer.d.ts.map +1 -1
- package/esri-compat/geojson-layer.js +4 -2
- package/esri-compat/identity-manager.js +2 -2
- package/esri-compat/imagery-layer.d.ts +2 -1
- package/esri-compat/imagery-layer.d.ts.map +1 -1
- package/esri-compat/imagery-layer.js +2 -1
- package/esri-compat/layer-list.d.ts +8 -1
- package/esri-compat/layer-list.d.ts.map +1 -1
- package/esri-compat/layer-list.js +11 -4
- package/esri-compat/legend.d.ts.map +1 -1
- package/esri-compat/legend.js +1 -1
- package/esri-compat/locator.d.ts +297 -0
- package/esri-compat/locator.d.ts.map +1 -0
- package/esri-compat/locator.js +579 -0
- package/esri-compat/map-image-layer.d.ts.map +1 -1
- package/esri-compat/map-view.d.ts.map +1 -1
- package/esri-compat/map-view.js +1 -1
- package/esri-compat/scene-view.d.ts.map +1 -1
- package/esri-compat/time-slider.d.ts +29 -0
- package/esri-compat/time-slider.d.ts.map +1 -1
- package/esri-compat/time-slider.js +117 -0
- package/esri-compat/wfs-layer.d.ts +2 -1
- package/esri-compat/wfs-layer.d.ts.map +1 -1
- package/esri-compat/wfs-layer.js +2 -1
- package/esri-compat/widget-host.d.ts +42 -6
- package/esri-compat/widget-host.d.ts.map +1 -1
- package/esri-compat/widget-host.js +99 -10
- package/generated-app/projection.d.ts.map +1 -1
- package/generated-app/runtime.d.ts.map +1 -1
- package/interactions/declarative-contract.d.ts +149 -0
- package/interactions/declarative-contract.d.ts.map +1 -0
- package/interactions/declarative-contract.js +293 -0
- package/interactions/declarative.d.ts +198 -0
- package/interactions/declarative.d.ts.map +1 -0
- package/interactions/declarative.js +470 -0
- package/interactions/feature-state.d.ts +7 -0
- package/interactions/feature-state.d.ts.map +1 -1
- package/interactions/feature-state.js +10 -2
- package/map/data-to-map-bridge.d.ts +1 -1
- package/map/data-to-map-bridge.d.ts.map +1 -1
- package/map/data-to-map-bridge.js +1 -2
- package/map/honua-map.d.ts.map +1 -1
- package/map/honua-map.js +1 -1
- package/map/index.d.ts +2 -0
- package/map/index.d.ts.map +1 -1
- package/map/index.js +2 -0
- package/map/maplibre-target.js +1 -1
- package/map/realtime-reconciliation-adapter.d.ts +98 -0
- package/map/realtime-reconciliation-adapter.d.ts.map +1 -0
- package/map/realtime-reconciliation-adapter.js +84 -0
- package/map/temporal-playback.d.ts +25 -0
- package/map/temporal-playback.d.ts.map +1 -1
- package/map/temporal-playback.js +29 -1
- package/package.json +4 -3
- package/query-planner/canonical-filter.d.ts +30 -0
- package/query-planner/canonical-filter.d.ts.map +1 -0
- package/query-planner/canonical-filter.js +30 -0
- package/query-planner/columnar-plan.d.ts +63 -0
- package/query-planner/columnar-plan.d.ts.map +1 -0
- package/query-planner/columnar-plan.js +94 -0
- package/query-planner/diagnostics.d.ts +3 -3
- package/query-planner/diagnostics.d.ts.map +1 -1
- package/query-planner/diagnostics.js +17 -4
- package/query-planner/duckdb.d.ts.map +1 -1
- package/query-planner/duckdb.js +16 -2
- package/query-planner/execution-context.d.ts.map +1 -1
- package/query-planner/execution-context.js +3 -1
- package/query-planner/executor.d.ts.map +1 -1
- package/query-planner/executor.js +7 -1
- package/query-planner/geoservices.d.ts.map +1 -1
- package/query-planner/geoservices.js +24 -5
- package/query-planner/grpc.d.ts.map +1 -1
- package/query-planner/grpc.js +29 -5
- package/query-planner/index.d.ts +20 -5
- package/query-planner/index.d.ts.map +1 -1
- package/query-planner/index.js +16 -3
- package/query-planner/ir.d.ts +6 -1
- package/query-planner/ir.d.ts.map +1 -1
- package/query-planner/ir.js +63 -1
- package/query-planner/odata-protocol-module.d.ts +54 -0
- package/query-planner/odata-protocol-module.d.ts.map +1 -0
- package/query-planner/odata-protocol-module.js +199 -0
- package/query-planner/odata-v1.d.ts +23 -0
- package/query-planner/odata-v1.d.ts.map +1 -0
- package/query-planner/odata-v1.js +202 -0
- package/query-planner/odata.d.ts +0 -3
- package/query-planner/odata.d.ts.map +1 -1
- package/query-planner/odata.js +0 -143
- package/query-planner/ogc-features.d.ts.map +1 -1
- package/query-planner/ogc-features.js +23 -1
- package/query-planner/planner.d.ts +2 -2
- package/query-planner/planner.d.ts.map +1 -1
- package/query-planner/planner.js +146 -27
- package/query-planner/representation.d.ts +59 -0
- package/query-planner/representation.d.ts.map +1 -0
- package/query-planner/representation.js +140 -0
- package/query-planner/types.d.ts +153 -3
- package/query-planner/types.d.ts.map +1 -1
- package/query-planner/wfs-protocol-module.d.ts +57 -0
- package/query-planner/wfs-protocol-module.d.ts.map +1 -0
- package/query-planner/wfs-protocol-module.js +731 -0
- package/query-planner/wfs-v1.d.ts +41 -0
- package/query-planner/wfs-v1.d.ts.map +1 -0
- package/query-planner/wfs-v1.js +366 -0
- package/query-planner/wfs.d.ts +1 -3
- package/query-planner/wfs.d.ts.map +1 -1
- package/query-planner/wfs.js +1 -70
- package/react/honua-map.d.ts +3 -1
- package/react/honua-map.d.ts.map +1 -1
- package/react/honua-map.js +11 -1
- package/react/hooks.d.ts +1 -1
- package/react/hooks.js +1 -1
- package/react/selection.d.ts.map +1 -1
- package/react/selection.js +0 -0
- package/react/use-mounted-source.d.ts +1 -1
- package/react/use-mounted-source.js +1 -1
- package/realtime/checkpoint-store.d.ts +206 -0
- package/realtime/checkpoint-store.d.ts.map +1 -0
- package/realtime/checkpoint-store.js +504 -0
- package/realtime/contract.d.ts +142 -0
- package/realtime/contract.d.ts.map +1 -0
- package/realtime/contract.js +154 -0
- package/realtime/exploration.d.ts +16 -0
- package/realtime/exploration.d.ts.map +1 -0
- package/realtime/exploration.js +32 -0
- package/realtime/honua-server.d.ts +131 -0
- package/realtime/honua-server.d.ts.map +1 -0
- package/realtime/honua-server.js +302 -0
- package/realtime/index.d.ts +39 -0
- package/realtime/index.d.ts.map +1 -0
- package/realtime/index.js +26 -0
- package/realtime/odata-delta.d.ts +114 -0
- package/realtime/odata-delta.d.ts.map +1 -0
- package/realtime/odata-delta.js +483 -0
- package/realtime/projections.d.ts +30 -0
- package/realtime/projections.d.ts.map +1 -0
- package/realtime/projections.js +51 -0
- package/realtime/reconciliation.d.ts +245 -0
- package/realtime/reconciliation.d.ts.map +1 -0
- package/realtime/reconciliation.js +377 -0
- package/realtime/reducer.d.ts +14 -0
- package/realtime/reducer.d.ts.map +1 -0
- package/realtime/reducer.js +351 -0
- package/realtime/resumable-transport.d.ts +111 -0
- package/realtime/resumable-transport.d.ts.map +1 -0
- package/realtime/resumable-transport.js +406 -0
- package/realtime/resumable.d.ts +104 -0
- package/realtime/resumable.d.ts.map +1 -0
- package/realtime/resumable.js +632 -0
- package/realtime/sse.d.ts +23 -0
- package/realtime/sse.d.ts.map +1 -0
- package/realtime/sse.js +153 -0
- package/realtime/store.d.ts +18 -0
- package/realtime/store.d.ts.map +1 -0
- package/realtime/store.js +63 -0
- package/realtime/types.d.ts +190 -0
- package/realtime/types.d.ts.map +1 -0
- package/realtime/types.js +10 -0
- package/realtime/websocket.d.ts +60 -0
- package/realtime/websocket.d.ts.map +1 -0
- package/realtime/websocket.js +157 -0
- package/realtime/wire.d.ts +28 -0
- package/realtime/wire.d.ts.map +1 -0
- package/realtime/wire.js +73 -0
- package/runtime/index.d.ts +6 -2
- package/runtime/index.d.ts.map +1 -1
- package/runtime/index.js +3 -1
- package/runtime/load-package.d.ts +24 -0
- package/runtime/load-package.d.ts.map +1 -1
- package/runtime/load-package.js +45 -1
- package/runtime/maplibre-renderer.d.ts +11 -0
- package/runtime/maplibre-renderer.d.ts.map +1 -1
- package/runtime/maplibre-renderer.js +32 -7
- package/runtime/offline-region-protocol.d.ts +73 -0
- package/runtime/offline-region-protocol.d.ts.map +1 -0
- package/runtime/offline-region-protocol.js +63 -0
- package/runtime/offline-region-style.d.ts +78 -0
- package/runtime/offline-region-style.d.ts.map +1 -0
- package/runtime/offline-region-style.js +147 -0
- package/runtime/pmtiles-protocol.d.ts +7 -8
- package/runtime/pmtiles-protocol.d.ts.map +1 -1
- package/runtime/pmtiles-protocol.js +19 -66
- package/runtime/protocol-registry.d.ts +55 -0
- package/runtime/protocol-registry.d.ts.map +1 -0
- package/runtime/protocol-registry.js +103 -0
- package/runtime/runtime.d.ts +2 -0
- package/runtime/runtime.d.ts.map +1 -1
- package/runtime/runtime.js +6 -1
- package/runtime/style-compose.d.ts.map +1 -1
- package/runtime/style-compose.js +78 -9
- package/studio/index.d.ts +24 -7
- package/studio/index.d.ts.map +1 -1
- package/studio/index.js +21 -7
- package/studio/lifecycle-client.d.ts +164 -0
- package/studio/lifecycle-client.d.ts.map +1 -0
- package/studio/lifecycle-client.js +299 -0
- package/studio/lifecycle-errors.d.ts +91 -0
- package/studio/lifecycle-errors.d.ts.map +1 -0
- package/studio/lifecycle-errors.js +77 -0
- package/studio/lifecycle-types.d.ts +321 -0
- package/studio/lifecycle-types.d.ts.map +1 -0
- package/studio/lifecycle-types.js +22 -0
- package/webmap/convert-label.js +0 -1
- package/webmap/convert-symbol.d.ts.map +1 -1
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical typed query filter — the protocol-neutral semantic filter AST that
|
|
3
|
+
* `Source.query()` accepts on the stable surface, plus the per-protocol
|
|
4
|
+
* compilers that lower it onto each wire dialect.
|
|
5
|
+
*
|
|
6
|
+
* The AST mirrors the experimental `/query-planner` semantic filter (comparison,
|
|
7
|
+
* list, range, null, pattern, spatial, temporal, boolean composition) with two
|
|
8
|
+
* deliberate contract-layer differences:
|
|
9
|
+
*
|
|
10
|
+
* - Spatial predicates carry the same canonical geometry value the rest of the
|
|
11
|
+
* contract uses (`SpatialFilter`-shaped `geometry` + `geometryType`, produced
|
|
12
|
+
* by `envelope()` / `point()` / `polygon()`), so a filter composes with
|
|
13
|
+
* `Query.spatialFilter` without a second geometry vocabulary.
|
|
14
|
+
* - Compilation is schema-free. The planner's semantic compilers verify a
|
|
15
|
+
* `SourceSchemaV2` before emitting; the canonical surface has no verified
|
|
16
|
+
* schema at query time, so literals are typed by the value the caller wrote.
|
|
17
|
+
*
|
|
18
|
+
* Every compiler in this module fails closed: a construct the target protocol
|
|
19
|
+
* cannot express exactly throws `HonuaCapabilityNotSupportedError` naming the
|
|
20
|
+
* construct and the protocol. Nothing is silently dropped and nothing is
|
|
21
|
+
* silently widened; a widening that is genuinely safe (the GeoParquet bbox
|
|
22
|
+
* reduction) is reported through `Result.degraded` by its adapter.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
import { HonuaCapabilityNotSupportedError } from "../core/errors.js";
|
|
27
|
+
// ── Builders ──────────────────────────────────────────────────
|
|
28
|
+
function property(name) {
|
|
29
|
+
return { kind: "property", name };
|
|
30
|
+
}
|
|
31
|
+
function literal(value) {
|
|
32
|
+
return { kind: "literal", value };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Ergonomic constructors for {@link QueryFilterExpression}. Every helper returns
|
|
36
|
+
* plain data, so a filter can equally be written as an object literal, stored as
|
|
37
|
+
* JSON, or produced by the experimental semantic builder.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { envelope, queryFilter } from "@honua/sdk-js";
|
|
42
|
+
*
|
|
43
|
+
* const filter = queryFilter.and(
|
|
44
|
+
* queryFilter.eq("STATUS", "open"),
|
|
45
|
+
* queryFilter.gt("SEVERITY", 3),
|
|
46
|
+
* queryFilter.spatial("intersects", envelope(-158.5, 21.2, -157.6, 21.7)),
|
|
47
|
+
* );
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export const queryFilter = /* @__PURE__ */ Object.freeze({
|
|
51
|
+
eq: (field, value) => ({
|
|
52
|
+
kind: "comparison",
|
|
53
|
+
operator: "eq",
|
|
54
|
+
left: property(field),
|
|
55
|
+
right: literal(value),
|
|
56
|
+
}),
|
|
57
|
+
ne: (field, value) => ({
|
|
58
|
+
kind: "comparison",
|
|
59
|
+
operator: "ne",
|
|
60
|
+
left: property(field),
|
|
61
|
+
right: literal(value),
|
|
62
|
+
}),
|
|
63
|
+
lt: (field, value) => ({
|
|
64
|
+
kind: "comparison",
|
|
65
|
+
operator: "lt",
|
|
66
|
+
left: property(field),
|
|
67
|
+
right: literal(value),
|
|
68
|
+
}),
|
|
69
|
+
lte: (field, value) => ({
|
|
70
|
+
kind: "comparison",
|
|
71
|
+
operator: "lte",
|
|
72
|
+
left: property(field),
|
|
73
|
+
right: literal(value),
|
|
74
|
+
}),
|
|
75
|
+
gt: (field, value) => ({
|
|
76
|
+
kind: "comparison",
|
|
77
|
+
operator: "gt",
|
|
78
|
+
left: property(field),
|
|
79
|
+
right: literal(value),
|
|
80
|
+
}),
|
|
81
|
+
gte: (field, value) => ({
|
|
82
|
+
kind: "comparison",
|
|
83
|
+
operator: "gte",
|
|
84
|
+
left: property(field),
|
|
85
|
+
right: literal(value),
|
|
86
|
+
}),
|
|
87
|
+
isIn: (field, values) => ({
|
|
88
|
+
kind: "list",
|
|
89
|
+
operator: "in",
|
|
90
|
+
operand: property(field),
|
|
91
|
+
values: values.map(literal),
|
|
92
|
+
}),
|
|
93
|
+
between: (field, lower, upper) => ({
|
|
94
|
+
kind: "range",
|
|
95
|
+
operator: "between",
|
|
96
|
+
operand: property(field),
|
|
97
|
+
lower: literal(lower),
|
|
98
|
+
upper: literal(upper),
|
|
99
|
+
}),
|
|
100
|
+
isNull: (field) => ({ kind: "null", operator: "is-null", operand: property(field) }),
|
|
101
|
+
isNotNull: (field) => ({
|
|
102
|
+
kind: "null",
|
|
103
|
+
operator: "is-not-null",
|
|
104
|
+
operand: property(field),
|
|
105
|
+
}),
|
|
106
|
+
like: (field, pattern, options = {}) => ({
|
|
107
|
+
kind: "pattern",
|
|
108
|
+
operator: "like",
|
|
109
|
+
operand: property(field),
|
|
110
|
+
pattern,
|
|
111
|
+
...(options.caseSensitive !== undefined ? { caseSensitive: options.caseSensitive } : {}),
|
|
112
|
+
}),
|
|
113
|
+
spatial: (operator, geometry, options = {}) => ({
|
|
114
|
+
kind: "spatial",
|
|
115
|
+
operator,
|
|
116
|
+
geometry: { geometry: geometry.geometry, geometryType: geometry.geometryType },
|
|
117
|
+
...(options.property !== undefined ? { property: options.property } : {}),
|
|
118
|
+
}),
|
|
119
|
+
before: (field, instant) => ({
|
|
120
|
+
kind: "temporal",
|
|
121
|
+
operator: "before",
|
|
122
|
+
operand: property(field),
|
|
123
|
+
value: { kind: "temporal-literal", valueType: "instant", value: instant },
|
|
124
|
+
}),
|
|
125
|
+
after: (field, instant) => ({
|
|
126
|
+
kind: "temporal",
|
|
127
|
+
operator: "after",
|
|
128
|
+
operand: property(field),
|
|
129
|
+
value: { kind: "temporal-literal", valueType: "instant", value: instant },
|
|
130
|
+
}),
|
|
131
|
+
during: (field, start, end) => ({
|
|
132
|
+
kind: "temporal",
|
|
133
|
+
operator: "during",
|
|
134
|
+
operand: property(field),
|
|
135
|
+
value: { kind: "temporal-literal", valueType: "interval", value: [start, end] },
|
|
136
|
+
}),
|
|
137
|
+
timeIntersects: (field, start, end) => ({
|
|
138
|
+
kind: "temporal",
|
|
139
|
+
operator: "time-intersects",
|
|
140
|
+
operand: property(field),
|
|
141
|
+
value: { kind: "temporal-literal", valueType: "interval", value: [start, end] },
|
|
142
|
+
}),
|
|
143
|
+
and: (...args) => ({
|
|
144
|
+
kind: "boolean",
|
|
145
|
+
operator: "and",
|
|
146
|
+
args,
|
|
147
|
+
}),
|
|
148
|
+
or: (...args) => ({
|
|
149
|
+
kind: "boolean",
|
|
150
|
+
operator: "or",
|
|
151
|
+
args,
|
|
152
|
+
}),
|
|
153
|
+
not: (arg) => ({ kind: "not", arg }),
|
|
154
|
+
});
|
|
155
|
+
// ── Validation ────────────────────────────────────────────────
|
|
156
|
+
/** Structural bounds mirroring the semantic planner's parser limits. */
|
|
157
|
+
export const MAX_QUERY_FILTER_DEPTH = 32;
|
|
158
|
+
export const MAX_QUERY_FILTER_NODES = 512;
|
|
159
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
160
|
+
const QUALIFIED_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:[.:][A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
161
|
+
const RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:[Zz]|[+-]\d{2}:\d{2})?)?$/;
|
|
162
|
+
/** Fail closed, naming the construct and the protocol that cannot express it. */
|
|
163
|
+
export function refuseQueryFilterConstruct(construct, ctx) {
|
|
164
|
+
throw new HonuaCapabilityNotSupportedError(construct, ctx.protocol, ctx.sourceId, {
|
|
165
|
+
context: { construct, filter: true },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fail closed because this source's evaluated capability evidence excludes the
|
|
170
|
+
* operator, even though its protocol can express it. The refusal names the same
|
|
171
|
+
* construct as a protocol refusal so callers branch on one vocabulary, and
|
|
172
|
+
* stamps `constraint: "capability-profile"` so the two causes stay
|
|
173
|
+
* distinguishable without parsing messages.
|
|
174
|
+
*/
|
|
175
|
+
function refuseUnsupportedOperator(construct, ctx) {
|
|
176
|
+
throw new HonuaCapabilityNotSupportedError(construct, ctx.protocol, ctx.sourceId, {
|
|
177
|
+
context: { construct, filter: true, constraint: ctx.constraintSource ?? "capability-profile" },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/** Gate one operator against the source's evaluated allow list. */
|
|
181
|
+
function assertOperatorAllowed(operator, construct, ctx) {
|
|
182
|
+
if (ctx.supportedFilterOperators && !ctx.supportedFilterOperators.includes(operator)) {
|
|
183
|
+
refuseUnsupportedOperator(construct, ctx);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const QUERY_FILTER_OPERATORS = new Set([
|
|
187
|
+
"eq",
|
|
188
|
+
"ne",
|
|
189
|
+
"lt",
|
|
190
|
+
"lte",
|
|
191
|
+
"gt",
|
|
192
|
+
"gte",
|
|
193
|
+
"in",
|
|
194
|
+
"between",
|
|
195
|
+
"is-null",
|
|
196
|
+
"is-not-null",
|
|
197
|
+
"like",
|
|
198
|
+
"and",
|
|
199
|
+
"or",
|
|
200
|
+
"not",
|
|
201
|
+
"intersects",
|
|
202
|
+
"contains",
|
|
203
|
+
"within",
|
|
204
|
+
"crosses",
|
|
205
|
+
"touches",
|
|
206
|
+
"overlaps",
|
|
207
|
+
"bbox-intersects",
|
|
208
|
+
"before",
|
|
209
|
+
"after",
|
|
210
|
+
"during",
|
|
211
|
+
"time-intersects",
|
|
212
|
+
]);
|
|
213
|
+
const QUERY_FILTER_SPATIAL_PREDICATES = new Set([
|
|
214
|
+
"intersects",
|
|
215
|
+
"contains",
|
|
216
|
+
"within",
|
|
217
|
+
"crosses",
|
|
218
|
+
"touches",
|
|
219
|
+
"overlaps",
|
|
220
|
+
"bbox-intersects",
|
|
221
|
+
]);
|
|
222
|
+
const QUERY_FILTER_TEMPORAL_PREDICATES = new Set([
|
|
223
|
+
"before",
|
|
224
|
+
"after",
|
|
225
|
+
"during",
|
|
226
|
+
"time-intersects",
|
|
227
|
+
]);
|
|
228
|
+
/**
|
|
229
|
+
* Narrow the evaluated `CapabilityConstraints` of one capability into the
|
|
230
|
+
* allow lists this compiler understands.
|
|
231
|
+
*
|
|
232
|
+
* Vocabularies that the canonical AST cannot name (distance predicates,
|
|
233
|
+
* extension identifiers) are dropped rather than translated, which fails closed
|
|
234
|
+
* by construction: a profile that advertises only constructs outside the AST
|
|
235
|
+
* yields an empty allow list, and every node of that kind is refused. Omitted
|
|
236
|
+
* constraint arrays stay `undefined` — "the evidence says nothing", not "the
|
|
237
|
+
* source supports nothing".
|
|
238
|
+
*/
|
|
239
|
+
export function queryFilterConstraints(constraints) {
|
|
240
|
+
const operators = constraints.filterOperators?.filter((value) => QUERY_FILTER_OPERATORS.has(value));
|
|
241
|
+
const spatial = constraints.spatialPredicates?.filter((value) => QUERY_FILTER_SPATIAL_PREDICATES.has(value));
|
|
242
|
+
const temporal = constraints.temporalPredicates?.filter((value) => QUERY_FILTER_TEMPORAL_PREDICATES.has(value));
|
|
243
|
+
return {
|
|
244
|
+
...(operators ? { supportedFilterOperators: operators } : {}),
|
|
245
|
+
...(spatial ? { supportedSpatialPredicates: spatial } : {}),
|
|
246
|
+
...(temporal ? { supportedTemporalPredicates: temporal } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Validate a caller-supplied filter before any dialect sees it. Rejects
|
|
251
|
+
* malformed nodes, unbounded nesting, and identifiers that are not plain
|
|
252
|
+
* source field names (the only identifiers every dialect can quote safely).
|
|
253
|
+
*/
|
|
254
|
+
export function assertQueryFilter(filter, ctx) {
|
|
255
|
+
let nodes = 0;
|
|
256
|
+
const visit = (node, depth) => {
|
|
257
|
+
nodes += 1;
|
|
258
|
+
if (depth > MAX_QUERY_FILTER_DEPTH)
|
|
259
|
+
refuseQueryFilterConstruct("filter.depth", ctx);
|
|
260
|
+
if (nodes > MAX_QUERY_FILTER_NODES)
|
|
261
|
+
refuseQueryFilterConstruct("filter.size", ctx);
|
|
262
|
+
if (!node || typeof node !== "object")
|
|
263
|
+
refuseQueryFilterConstruct("filter.node", ctx);
|
|
264
|
+
switch (node.kind) {
|
|
265
|
+
case "comparison":
|
|
266
|
+
assertFieldName(node.left?.name, ctx);
|
|
267
|
+
assertScalar(node.right, ctx);
|
|
268
|
+
assertOperatorAllowed(node.operator, `filter.comparison.${node.operator}`, ctx);
|
|
269
|
+
return;
|
|
270
|
+
case "list":
|
|
271
|
+
assertFieldName(node.operand?.name, ctx);
|
|
272
|
+
if (!Array.isArray(node.values) || node.values.length === 0)
|
|
273
|
+
refuseQueryFilterConstruct("filter.list.values", ctx);
|
|
274
|
+
for (const value of node.values)
|
|
275
|
+
assertScalar(value, ctx);
|
|
276
|
+
assertOperatorAllowed("in", "filter.list.in", ctx);
|
|
277
|
+
return;
|
|
278
|
+
case "range":
|
|
279
|
+
assertFieldName(node.operand?.name, ctx);
|
|
280
|
+
assertScalar(node.lower, ctx);
|
|
281
|
+
assertScalar(node.upper, ctx);
|
|
282
|
+
assertOperatorAllowed("between", "filter.range.between", ctx);
|
|
283
|
+
return;
|
|
284
|
+
case "null":
|
|
285
|
+
assertFieldName(node.operand?.name, ctx);
|
|
286
|
+
assertOperatorAllowed(node.operator, `filter.null.${node.operator}`, ctx);
|
|
287
|
+
return;
|
|
288
|
+
case "pattern":
|
|
289
|
+
assertFieldName(node.operand?.name, ctx);
|
|
290
|
+
if (typeof node.pattern !== "string" || node.pattern.length === 0)
|
|
291
|
+
refuseQueryFilterConstruct("filter.pattern.value", ctx);
|
|
292
|
+
assertPrintable(node.pattern, "filter.pattern.value", ctx);
|
|
293
|
+
assertOperatorAllowed("like", "filter.pattern.like", ctx);
|
|
294
|
+
return;
|
|
295
|
+
case "spatial":
|
|
296
|
+
if (node.property !== undefined)
|
|
297
|
+
assertFieldName(node.property, ctx, true);
|
|
298
|
+
if (!node.geometry || typeof node.geometry !== "object" || typeof node.geometry.geometry !== "object") {
|
|
299
|
+
refuseQueryFilterConstruct("filter.spatial.geometry", ctx);
|
|
300
|
+
}
|
|
301
|
+
if (ctx.supportedSpatialPredicates && !ctx.supportedSpatialPredicates.includes(node.operator)) {
|
|
302
|
+
refuseUnsupportedOperator(`filter.spatial.${node.operator}`, ctx);
|
|
303
|
+
}
|
|
304
|
+
assertOperatorAllowed(node.operator, `filter.spatial.${node.operator}`, ctx);
|
|
305
|
+
return;
|
|
306
|
+
case "temporal":
|
|
307
|
+
assertFieldName(node.operand?.name, ctx, true);
|
|
308
|
+
assertTemporalLiteral(node.value, ctx);
|
|
309
|
+
if (ctx.supportedTemporalPredicates && !ctx.supportedTemporalPredicates.includes(node.operator)) {
|
|
310
|
+
refuseUnsupportedOperator(`filter.temporal.${node.operator}`, ctx);
|
|
311
|
+
}
|
|
312
|
+
assertOperatorAllowed(node.operator, `filter.temporal.${node.operator}`, ctx);
|
|
313
|
+
return;
|
|
314
|
+
case "boolean":
|
|
315
|
+
if (!Array.isArray(node.args) || node.args.length === 0)
|
|
316
|
+
refuseQueryFilterConstruct("filter.boolean.args", ctx);
|
|
317
|
+
assertOperatorAllowed(node.operator, `filter.boolean.${node.operator}`, ctx);
|
|
318
|
+
for (const arg of node.args)
|
|
319
|
+
visit(arg, depth + 1);
|
|
320
|
+
return;
|
|
321
|
+
case "not":
|
|
322
|
+
assertOperatorAllowed("not", "filter.not", ctx);
|
|
323
|
+
visit(node.arg, depth + 1);
|
|
324
|
+
return;
|
|
325
|
+
default:
|
|
326
|
+
refuseQueryFilterConstruct("filter.node", ctx);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
visit(filter, 1);
|
|
330
|
+
}
|
|
331
|
+
function assertFieldName(name, ctx, qualified = false) {
|
|
332
|
+
const pattern = qualified ? QUALIFIED_IDENTIFIER_PATTERN : IDENTIFIER_PATTERN;
|
|
333
|
+
if (typeof name !== "string" || name.length === 0 || name.length > 128 || !pattern.test(name)) {
|
|
334
|
+
refuseQueryFilterConstruct("filter.property.name", ctx);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function assertScalar(node, ctx) {
|
|
338
|
+
if (!node || typeof node !== "object" || node.kind !== "literal") {
|
|
339
|
+
refuseQueryFilterConstruct("filter.literal", ctx);
|
|
340
|
+
}
|
|
341
|
+
const value = node.value;
|
|
342
|
+
if (value === null || typeof value === "boolean")
|
|
343
|
+
return;
|
|
344
|
+
if (typeof value === "number") {
|
|
345
|
+
if (!Number.isFinite(value))
|
|
346
|
+
refuseQueryFilterConstruct("filter.literal.number", ctx);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (typeof value === "string") {
|
|
350
|
+
assertPrintable(value, "filter.literal.text", ctx);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
refuseQueryFilterConstruct("filter.literal", ctx);
|
|
354
|
+
}
|
|
355
|
+
function assertPrintable(value, construct, ctx) {
|
|
356
|
+
if (value.length > 4096)
|
|
357
|
+
refuseQueryFilterConstruct(construct, ctx);
|
|
358
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
359
|
+
const code = value.charCodeAt(index);
|
|
360
|
+
if (code <= 0x1f || code === 0x7f)
|
|
361
|
+
refuseQueryFilterConstruct(construct, ctx);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function assertTemporalLiteral(value, ctx) {
|
|
365
|
+
const node = value;
|
|
366
|
+
if (!node || node.kind !== "temporal-literal")
|
|
367
|
+
refuseQueryFilterConstruct("filter.temporal.literal", ctx);
|
|
368
|
+
if (node.valueType === "instant") {
|
|
369
|
+
assertInstant(node.value, ctx);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (node.valueType === "interval") {
|
|
373
|
+
if (!Array.isArray(node.value) || node.value.length !== 2)
|
|
374
|
+
refuseQueryFilterConstruct("filter.temporal.interval", ctx);
|
|
375
|
+
assertInstant(node.value[0], ctx);
|
|
376
|
+
assertInstant(node.value[1], ctx);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
refuseQueryFilterConstruct("filter.temporal.literal", ctx);
|
|
380
|
+
}
|
|
381
|
+
function assertInstant(value, ctx) {
|
|
382
|
+
if (typeof value !== "string" || !RFC3339_PATTERN.test(value))
|
|
383
|
+
refuseQueryFilterConstruct("filter.temporal.instant", ctx);
|
|
384
|
+
}
|
|
385
|
+
/** Validate `Query.temporalFilter` independently of the filter AST. */
|
|
386
|
+
export function assertTemporalFilter(temporal, ctx) {
|
|
387
|
+
if (!temporal || typeof temporal !== "object")
|
|
388
|
+
refuseQueryFilterConstruct("temporalFilter", ctx);
|
|
389
|
+
if (temporal.field !== undefined)
|
|
390
|
+
assertFieldName(temporal.field, ctx, true);
|
|
391
|
+
if (temporal.kind === "instant") {
|
|
392
|
+
assertInstant(temporal.instant, ctx);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (temporal.kind === "interval") {
|
|
396
|
+
if (temporal.start === null && temporal.end === null)
|
|
397
|
+
refuseQueryFilterConstruct("temporalFilter.interval", ctx);
|
|
398
|
+
if (temporal.start !== null)
|
|
399
|
+
assertInstant(temporal.start, ctx);
|
|
400
|
+
if (temporal.end !== null)
|
|
401
|
+
assertInstant(temporal.end, ctx);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
refuseQueryFilterConstruct("temporalFilter", ctx);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Lower a field-bound `Query.temporalFilter` into a filter node so every
|
|
408
|
+
* dialect compiles temporal intent through the same path. Returns `undefined`
|
|
409
|
+
* when the filter targets the source's own time dimension (no `field`), which
|
|
410
|
+
* the protocol time parameter carries instead.
|
|
411
|
+
*/
|
|
412
|
+
export function temporalFilterToExpression(temporal, ctx) {
|
|
413
|
+
assertTemporalFilter(temporal, ctx);
|
|
414
|
+
if (temporal.field === undefined)
|
|
415
|
+
return undefined;
|
|
416
|
+
const field = temporal.field;
|
|
417
|
+
if (temporal.kind === "instant") {
|
|
418
|
+
return {
|
|
419
|
+
kind: "temporal",
|
|
420
|
+
operator: "time-intersects",
|
|
421
|
+
operand: { kind: "property", name: field },
|
|
422
|
+
value: { kind: "temporal-literal", valueType: "instant", value: temporal.instant },
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (temporal.start !== null && temporal.end !== null) {
|
|
426
|
+
return {
|
|
427
|
+
kind: "temporal",
|
|
428
|
+
operator: "during",
|
|
429
|
+
operand: { kind: "property", name: field },
|
|
430
|
+
value: { kind: "temporal-literal", valueType: "interval", value: [temporal.start, temporal.end] },
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// A half-open canonical interval keeps its closed bound INCLUSIVE, so it
|
|
434
|
+
// lowers to an ordered comparison rather than the strict `after` / `before`
|
|
435
|
+
// temporal predicates. `[start, ..)` means "at or after start".
|
|
436
|
+
if (temporal.start !== null) {
|
|
437
|
+
return {
|
|
438
|
+
kind: "comparison",
|
|
439
|
+
operator: "gte",
|
|
440
|
+
left: { kind: "property", name: field },
|
|
441
|
+
right: { kind: "literal", value: temporal.start },
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (temporal.end !== null) {
|
|
445
|
+
return {
|
|
446
|
+
kind: "comparison",
|
|
447
|
+
operator: "lte",
|
|
448
|
+
left: { kind: "property", name: field },
|
|
449
|
+
right: { kind: "literal", value: temporal.end },
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return refuseQueryFilterConstruct("temporalFilter.interval", ctx);
|
|
453
|
+
}
|
|
454
|
+
/** Combine a filter and a lowered temporal predicate into one expression. */
|
|
455
|
+
export function andQueryFilters(...parts) {
|
|
456
|
+
const present = parts.filter((part) => part !== undefined);
|
|
457
|
+
if (present.length === 0)
|
|
458
|
+
return undefined;
|
|
459
|
+
if (present.length === 1)
|
|
460
|
+
return present[0];
|
|
461
|
+
return { kind: "boolean", operator: "and", args: present };
|
|
462
|
+
}
|
|
463
|
+
// ── Shared literal helpers ────────────────────────────────────
|
|
464
|
+
function sqlText(value) {
|
|
465
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
466
|
+
}
|
|
467
|
+
function sqlTimestamp(instant) {
|
|
468
|
+
return `TIMESTAMP ${sqlText(instant)}`;
|
|
469
|
+
}
|
|
470
|
+
function isTemporalLike(value) {
|
|
471
|
+
return typeof value === "string" && RFC3339_PATTERN.test(value) && value.length >= 10 && value.includes("-");
|
|
472
|
+
}
|
|
473
|
+
/** SQL-92 / DuckDB scalar literal. Temporal-shaped strings become timestamps. */
|
|
474
|
+
function sqlLiteral(node, temporal) {
|
|
475
|
+
const value = node.value;
|
|
476
|
+
if (value === null)
|
|
477
|
+
return "NULL";
|
|
478
|
+
if (typeof value === "boolean")
|
|
479
|
+
return value ? "1" : "0";
|
|
480
|
+
if (typeof value === "number")
|
|
481
|
+
return String(value);
|
|
482
|
+
return temporal && isTemporalLike(value) ? sqlTimestamp(value) : sqlText(value);
|
|
483
|
+
}
|
|
484
|
+
const SQL_COMPARISON = {
|
|
485
|
+
eq: "=",
|
|
486
|
+
ne: "<>",
|
|
487
|
+
lt: "<",
|
|
488
|
+
lte: "<=",
|
|
489
|
+
gt: ">",
|
|
490
|
+
gte: ">=",
|
|
491
|
+
};
|
|
492
|
+
const ODATA_COMPARISON = {
|
|
493
|
+
eq: "eq",
|
|
494
|
+
ne: "ne",
|
|
495
|
+
lt: "lt",
|
|
496
|
+
lte: "le",
|
|
497
|
+
gt: "gt",
|
|
498
|
+
gte: "ge",
|
|
499
|
+
};
|
|
500
|
+
/**
|
|
501
|
+
* Resolve a temporal node into bounds plus their strictness.
|
|
502
|
+
*
|
|
503
|
+
* `before` and `after` are *strict*: that is what CQL2 `T_BEFORE` / `T_AFTER`,
|
|
504
|
+
* FES `Before` / `After`, and the schema-verified planner compilers all mean, so
|
|
505
|
+
* the canonical lowering agrees with them rather than quietly widening the
|
|
506
|
+
* window by one instant. `during` and `time-intersects` keep both bounds
|
|
507
|
+
* inclusive, which is the closed-interval reading the reference evaluator and
|
|
508
|
+
* the conformance corpus use.
|
|
509
|
+
*/
|
|
510
|
+
function temporalBounds(node, ctx) {
|
|
511
|
+
const value = node.value;
|
|
512
|
+
if (value.valueType === "instant") {
|
|
513
|
+
if (node.operator === "before")
|
|
514
|
+
return { end: { value: value.value, inclusive: false } };
|
|
515
|
+
if (node.operator === "after")
|
|
516
|
+
return { start: { value: value.value, inclusive: false } };
|
|
517
|
+
return { instant: value.value };
|
|
518
|
+
}
|
|
519
|
+
if (node.operator === "before")
|
|
520
|
+
return { end: { value: value.value[0], inclusive: false } };
|
|
521
|
+
if (node.operator === "after")
|
|
522
|
+
return { start: { value: value.value[1], inclusive: false } };
|
|
523
|
+
if (node.operator === "during" || node.operator === "time-intersects") {
|
|
524
|
+
return {
|
|
525
|
+
start: { value: value.value[0], inclusive: true },
|
|
526
|
+
end: { value: value.value[1], inclusive: true },
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
return refuseQueryFilterConstruct(`filter.temporal.${node.operator}`, ctx);
|
|
530
|
+
}
|
|
531
|
+
/** Esri spatial relationships the canonical predicates map onto. @internal */
|
|
532
|
+
export const GEOSERVICES_SPATIAL_REL = {
|
|
533
|
+
intersects: "esriSpatialRelIntersects",
|
|
534
|
+
contains: "esriSpatialRelContains",
|
|
535
|
+
within: "esriSpatialRelWithin",
|
|
536
|
+
crosses: "esriSpatialRelCrosses",
|
|
537
|
+
touches: "esriSpatialRelTouches",
|
|
538
|
+
overlaps: "esriSpatialRelOverlaps",
|
|
539
|
+
// `bbox-intersects` means "the feature intersects this envelope", which is an
|
|
540
|
+
// ordinary intersects test against an envelope operand — the same lowering the
|
|
541
|
+
// schema-verified planner compiler uses. `esriSpatialRelEnvelopeIntersects`
|
|
542
|
+
// would instead compare the *feature's* envelope and return a superset.
|
|
543
|
+
"bbox-intersects": "esriSpatialRelIntersects",
|
|
544
|
+
};
|
|
545
|
+
/**
|
|
546
|
+
* Compile to the GeoServices `where` clause plus (at most one) spatial request
|
|
547
|
+
* parameter set. GeoServices expresses exactly one geometry per `query`
|
|
548
|
+
* request, and only conjunctively, so a spatial predicate nested inside `OR` /
|
|
549
|
+
* `NOT` or a second spatial predicate is refused rather than approximated.
|
|
550
|
+
*/
|
|
551
|
+
export function compileQueryFilterToSql92(filter, ctx) {
|
|
552
|
+
assertQueryFilter(filter, ctx);
|
|
553
|
+
const spatial = [];
|
|
554
|
+
const dialect = sql92Dialect(ctx);
|
|
555
|
+
const where = splitConjunctiveSpatial(filter, ctx, spatial, (node) => emitSql(node, ctx, dialect));
|
|
556
|
+
if (spatial.length > 1)
|
|
557
|
+
refuseQueryFilterConstruct("filter.spatial.multiple", ctx);
|
|
558
|
+
const node = spatial[0];
|
|
559
|
+
return {
|
|
560
|
+
...(where !== undefined ? { where } : {}),
|
|
561
|
+
...(node ? { spatialFilter: geoServicesSpatialFilter(node, ctx) } : {}),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function geoServicesSpatialFilter(node, ctx) {
|
|
565
|
+
const spatialRel = GEOSERVICES_SPATIAL_REL[node.operator];
|
|
566
|
+
if (!spatialRel)
|
|
567
|
+
refuseQueryFilterConstruct(`filter.spatial.${node.operator}`, ctx);
|
|
568
|
+
return {
|
|
569
|
+
geometry: node.geometry.geometry,
|
|
570
|
+
geometryType: node.geometry.geometryType,
|
|
571
|
+
spatialRel,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Split a top-level conjunction into "spatial predicates carried out of band"
|
|
576
|
+
* and "everything else, compiled by `emit`". A spatial node under `OR` / `NOT`
|
|
577
|
+
* cannot be carried out of band without changing the result set, so it throws.
|
|
578
|
+
*/
|
|
579
|
+
function splitConjunctiveSpatial(filter, ctx, spatial, emit) {
|
|
580
|
+
if (filter.kind === "spatial") {
|
|
581
|
+
spatial.push(filter);
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
if (filter.kind === "boolean" && filter.operator === "and") {
|
|
585
|
+
const parts = [];
|
|
586
|
+
for (const arg of filter.args) {
|
|
587
|
+
const compiled = splitConjunctiveSpatial(arg, ctx, spatial, emit);
|
|
588
|
+
if (compiled !== undefined)
|
|
589
|
+
parts.push(compiled);
|
|
590
|
+
}
|
|
591
|
+
if (parts.length === 0)
|
|
592
|
+
return undefined;
|
|
593
|
+
return parts.length === 1 ? parts[0] : parts.map((part) => `(${part})`).join(" AND ");
|
|
594
|
+
}
|
|
595
|
+
if (containsSpatial(filter))
|
|
596
|
+
refuseQueryFilterConstruct("filter.spatial.disjunction", ctx);
|
|
597
|
+
return emit(filter);
|
|
598
|
+
}
|
|
599
|
+
/** True when a filter references geometry anywhere in its tree. */
|
|
600
|
+
export function queryFilterHasSpatial(filter) {
|
|
601
|
+
return containsSpatial(filter);
|
|
602
|
+
}
|
|
603
|
+
function containsSpatial(filter) {
|
|
604
|
+
if (filter.kind === "spatial")
|
|
605
|
+
return true;
|
|
606
|
+
if (filter.kind === "boolean")
|
|
607
|
+
return filter.args.some(containsSpatial);
|
|
608
|
+
if (filter.kind === "not")
|
|
609
|
+
return containsSpatial(filter.arg);
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
function emitSql(filter, ctx, dialect) {
|
|
613
|
+
const field = (name) => dialect.identifier(name);
|
|
614
|
+
switch (filter.kind) {
|
|
615
|
+
case "comparison": {
|
|
616
|
+
const left = field(filter.left.name);
|
|
617
|
+
if (filter.right.value === null) {
|
|
618
|
+
return filter.operator === "eq"
|
|
619
|
+
? `${left} IS NULL`
|
|
620
|
+
: filter.operator === "ne"
|
|
621
|
+
? `${left} IS NOT NULL`
|
|
622
|
+
: refuseQueryFilterConstruct(`filter.comparison.${filter.operator}.null`, ctx);
|
|
623
|
+
}
|
|
624
|
+
return `${left} ${SQL_COMPARISON[filter.operator]} ${sqlLiteral(filter.right, true)}`;
|
|
625
|
+
}
|
|
626
|
+
case "list":
|
|
627
|
+
return `${field(filter.operand.name)} IN (${filter.values.map((value) => sqlLiteral(value, true)).join(", ")})`;
|
|
628
|
+
case "range":
|
|
629
|
+
return `${field(filter.operand.name)} BETWEEN ${sqlLiteral(filter.lower, true)} AND ${sqlLiteral(filter.upper, true)}`;
|
|
630
|
+
case "null":
|
|
631
|
+
return `${field(filter.operand.name)} IS ${filter.operator === "is-null" ? "NULL" : "NOT NULL"}`;
|
|
632
|
+
case "pattern": {
|
|
633
|
+
const pattern = sqlText(filter.pattern);
|
|
634
|
+
const operand = field(filter.operand.name);
|
|
635
|
+
return filter.caseSensitive === false
|
|
636
|
+
? dialect.caseInsensitiveLike(operand, pattern)
|
|
637
|
+
: `${operand} LIKE ${pattern}`;
|
|
638
|
+
}
|
|
639
|
+
case "temporal": {
|
|
640
|
+
const bounds = temporalBounds(filter, ctx);
|
|
641
|
+
const operand = field(filter.operand.name);
|
|
642
|
+
if (bounds.instant !== undefined)
|
|
643
|
+
return `${operand} = ${sqlTimestamp(bounds.instant)}`;
|
|
644
|
+
const parts = [];
|
|
645
|
+
if (bounds.start !== undefined) {
|
|
646
|
+
parts.push(`${operand} ${bounds.start.inclusive ? ">=" : ">"} ${sqlTimestamp(bounds.start.value)}`);
|
|
647
|
+
}
|
|
648
|
+
if (bounds.end !== undefined) {
|
|
649
|
+
parts.push(`${operand} ${bounds.end.inclusive ? "<=" : "<"} ${sqlTimestamp(bounds.end.value)}`);
|
|
650
|
+
}
|
|
651
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
|
|
652
|
+
}
|
|
653
|
+
case "boolean":
|
|
654
|
+
return filter.args
|
|
655
|
+
.map((arg) => `(${emitSql(arg, ctx, dialect)})`)
|
|
656
|
+
.join(filter.operator === "and" ? " AND " : " OR ");
|
|
657
|
+
case "not":
|
|
658
|
+
return `NOT (${emitSql(filter.arg, ctx, dialect)})`;
|
|
659
|
+
case "spatial":
|
|
660
|
+
return dialect.spatial(filter);
|
|
661
|
+
default:
|
|
662
|
+
return refuseQueryFilterConstruct("filter.node", ctx);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/** GeoServices carries geometry as request parameters, never inside the SQL. */
|
|
666
|
+
function sql92Dialect(ctx) {
|
|
667
|
+
return {
|
|
668
|
+
identifier: (name) => name,
|
|
669
|
+
caseInsensitiveLike: (operand, pattern) => `UPPER(${operand}) LIKE UPPER(${pattern})`,
|
|
670
|
+
spatial: () => refuseQueryFilterConstruct("filter.spatial.disjunction", ctx),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Compile to a DuckDB `WHERE` fragment. Spatial predicates are emitted through
|
|
675
|
+
* `emitSpatial`, which the GeoParquet driver supplies (it owns the geometry
|
|
676
|
+
* column, its encoding, and the bbox reduction that `Result.degraded` reports).
|
|
677
|
+
*/
|
|
678
|
+
export function compileQueryFilterToDuckDbSql(filter, ctx, emitSpatial) {
|
|
679
|
+
assertQueryFilter(filter, ctx);
|
|
680
|
+
let bboxApproximated = false;
|
|
681
|
+
const sql = emitSql(filter, ctx, {
|
|
682
|
+
identifier: (name) => `"${name}"`,
|
|
683
|
+
caseInsensitiveLike: (operand, pattern) => `${operand} ILIKE ${pattern}`,
|
|
684
|
+
spatial: (node) => {
|
|
685
|
+
const compiled = emitSpatial(node);
|
|
686
|
+
if (compiled.bboxApproximated)
|
|
687
|
+
bboxApproximated = true;
|
|
688
|
+
return compiled.sql;
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
return { sql, bboxApproximated };
|
|
692
|
+
}
|
|
693
|
+
// ── CQL2 text (OGC API Features / Records / STAC) ─────────────
|
|
694
|
+
const CQL2_SPATIAL = {
|
|
695
|
+
intersects: "S_INTERSECTS",
|
|
696
|
+
contains: "S_CONTAINS",
|
|
697
|
+
within: "S_WITHIN",
|
|
698
|
+
crosses: "S_CROSSES",
|
|
699
|
+
touches: "S_TOUCHES",
|
|
700
|
+
overlaps: "S_OVERLAPS",
|
|
701
|
+
"bbox-intersects": "S_INTERSECTS",
|
|
702
|
+
};
|
|
703
|
+
/**
|
|
704
|
+
* Compile to CQL2 text (OGC API — Features Part 3 / STAC filter extension).
|
|
705
|
+
* `geometryToWkt` is injected so the WKT writer stays in the OData module that
|
|
706
|
+
* already owns it instead of being duplicated here.
|
|
707
|
+
*/
|
|
708
|
+
export function compileQueryFilterToCql2Text(filter, ctx, geometryToWkt) {
|
|
709
|
+
assertQueryFilter(filter, ctx);
|
|
710
|
+
const emit = (node) => {
|
|
711
|
+
switch (node.kind) {
|
|
712
|
+
case "comparison": {
|
|
713
|
+
if (node.right.value === null) {
|
|
714
|
+
return node.operator === "eq"
|
|
715
|
+
? `${node.left.name} IS NULL`
|
|
716
|
+
: node.operator === "ne"
|
|
717
|
+
? `${node.left.name} IS NOT NULL`
|
|
718
|
+
: refuseQueryFilterConstruct(`filter.comparison.${node.operator}.null`, ctx);
|
|
719
|
+
}
|
|
720
|
+
return `${node.left.name} ${SQL_COMPARISON[node.operator]} ${cql2Literal(node.right)}`;
|
|
721
|
+
}
|
|
722
|
+
case "list":
|
|
723
|
+
return `${node.operand.name} IN (${node.values.map(cql2Literal).join(", ")})`;
|
|
724
|
+
case "range":
|
|
725
|
+
return `${node.operand.name} BETWEEN ${cql2Literal(node.lower)} AND ${cql2Literal(node.upper)}`;
|
|
726
|
+
case "null":
|
|
727
|
+
return `${node.operand.name} IS ${node.operator === "is-null" ? "NULL" : "NOT NULL"}`;
|
|
728
|
+
case "pattern":
|
|
729
|
+
return node.caseSensitive === false
|
|
730
|
+
? `CASEI(${node.operand.name}) LIKE CASEI(${sqlText(node.pattern)})`
|
|
731
|
+
: `${node.operand.name} LIKE ${sqlText(node.pattern)}`;
|
|
732
|
+
case "spatial": {
|
|
733
|
+
const wkt = geometryToWkt(node.geometry.geometry, node.geometry.geometryType);
|
|
734
|
+
const property = node.property ?? ctx.geometryProperty ?? "geometry";
|
|
735
|
+
return `${CQL2_SPATIAL[node.operator]}(${property}, ${wkt})`;
|
|
736
|
+
}
|
|
737
|
+
case "temporal": {
|
|
738
|
+
const field = node.operand.name;
|
|
739
|
+
const value = node.value;
|
|
740
|
+
if (node.operator === "before") {
|
|
741
|
+
return `T_BEFORE(${field}, ${cql2Instant(value.valueType === "instant" ? value.value : value.value[0])})`;
|
|
742
|
+
}
|
|
743
|
+
if (node.operator === "after") {
|
|
744
|
+
return `T_AFTER(${field}, ${cql2Instant(value.valueType === "instant" ? value.value : value.value[1])})`;
|
|
745
|
+
}
|
|
746
|
+
if (value.valueType === "instant") {
|
|
747
|
+
return `T_INTERSECTS(${field}, ${cql2Instant(value.value)})`;
|
|
748
|
+
}
|
|
749
|
+
const interval = `INTERVAL('${value.value[0]}', '${value.value[1]}')`;
|
|
750
|
+
return node.operator === "during" ? `T_DURING(${field}, ${interval})` : `T_INTERSECTS(${field}, ${interval})`;
|
|
751
|
+
}
|
|
752
|
+
case "boolean":
|
|
753
|
+
return node.args.map((arg) => `(${emit(arg)})`).join(node.operator === "and" ? " AND " : " OR ");
|
|
754
|
+
case "not":
|
|
755
|
+
return `NOT (${emit(node.arg)})`;
|
|
756
|
+
default:
|
|
757
|
+
return refuseQueryFilterConstruct("filter.node", ctx);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
return emit(filter);
|
|
761
|
+
}
|
|
762
|
+
function cql2Literal(node) {
|
|
763
|
+
const value = node.value;
|
|
764
|
+
if (value === null)
|
|
765
|
+
return "NULL";
|
|
766
|
+
if (typeof value === "boolean")
|
|
767
|
+
return value ? "TRUE" : "FALSE";
|
|
768
|
+
if (typeof value === "number")
|
|
769
|
+
return String(value);
|
|
770
|
+
return isTemporalLike(value) ? cql2Instant(value) : sqlText(value);
|
|
771
|
+
}
|
|
772
|
+
function cql2Instant(instant) {
|
|
773
|
+
return instant.length === 10 ? `DATE('${instant}')` : `TIMESTAMP('${instant}')`;
|
|
774
|
+
}
|
|
775
|
+
// ── OData v4 ($filter) ────────────────────────────────────────
|
|
776
|
+
/**
|
|
777
|
+
* Compile to an OData `$filter` expression. Spatial predicates are emitted
|
|
778
|
+
* through `emitSpatial` so the adapter's `$metadata`-aware geometry column and
|
|
779
|
+
* SRID resolution stay in one place.
|
|
780
|
+
*/
|
|
781
|
+
export function compileQueryFilterToOData(filter, ctx, emitSpatial) {
|
|
782
|
+
assertQueryFilter(filter, ctx);
|
|
783
|
+
const emit = (node) => {
|
|
784
|
+
switch (node.kind) {
|
|
785
|
+
case "comparison":
|
|
786
|
+
return `${node.left.name} ${ODATA_COMPARISON[node.operator]} ${odataLiteral(node.right)}`;
|
|
787
|
+
case "list":
|
|
788
|
+
return `${node.operand.name} in (${node.values.map(odataLiteral).join(",")})`;
|
|
789
|
+
case "range":
|
|
790
|
+
return `(${node.operand.name} ge ${odataLiteral(node.lower)} and ${node.operand.name} le ${odataLiteral(node.upper)})`;
|
|
791
|
+
case "null":
|
|
792
|
+
return `${node.operand.name} ${node.operator === "is-null" ? "eq" : "ne"} null`;
|
|
793
|
+
case "pattern":
|
|
794
|
+
return odataPattern(node, ctx);
|
|
795
|
+
case "spatial":
|
|
796
|
+
return emitSpatial(node);
|
|
797
|
+
case "temporal": {
|
|
798
|
+
const bounds = temporalBounds(node, ctx);
|
|
799
|
+
const field = node.operand.name;
|
|
800
|
+
if (bounds.instant !== undefined)
|
|
801
|
+
return `${field} eq ${bounds.instant}`;
|
|
802
|
+
const parts = [];
|
|
803
|
+
if (bounds.start !== undefined) {
|
|
804
|
+
parts.push(`${field} ${bounds.start.inclusive ? "ge" : "gt"} ${bounds.start.value}`);
|
|
805
|
+
}
|
|
806
|
+
if (bounds.end !== undefined) {
|
|
807
|
+
parts.push(`${field} ${bounds.end.inclusive ? "le" : "lt"} ${bounds.end.value}`);
|
|
808
|
+
}
|
|
809
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(" and ")})`;
|
|
810
|
+
}
|
|
811
|
+
case "boolean":
|
|
812
|
+
return node.args.map((arg) => `(${emit(arg)})`).join(node.operator === "and" ? " and " : " or ");
|
|
813
|
+
case "not":
|
|
814
|
+
return `not (${emit(node.arg)})`;
|
|
815
|
+
default:
|
|
816
|
+
return refuseQueryFilterConstruct("filter.node", ctx);
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
return emit(filter);
|
|
820
|
+
}
|
|
821
|
+
function odataLiteral(node) {
|
|
822
|
+
const value = node.value;
|
|
823
|
+
if (value === null)
|
|
824
|
+
return "null";
|
|
825
|
+
if (typeof value === "boolean")
|
|
826
|
+
return value ? "true" : "false";
|
|
827
|
+
if (typeof value === "number")
|
|
828
|
+
return String(value);
|
|
829
|
+
// Edm.DateTimeOffset literals are unquoted; every other string is quoted.
|
|
830
|
+
return isTemporalLike(value) && value.length > 10 ? value : sqlText(value);
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* OData has no `LIKE`. Only the three anchored shapes map exactly onto
|
|
834
|
+
* `startswith` / `endswith` / `contains`; anything else (an interior wildcard,
|
|
835
|
+
* a single-character `_`) is refused instead of being widened.
|
|
836
|
+
*/
|
|
837
|
+
function odataPattern(node, ctx) {
|
|
838
|
+
if (node.caseSensitive === false)
|
|
839
|
+
refuseQueryFilterConstruct("filter.pattern.case-insensitive", ctx);
|
|
840
|
+
const pattern = node.pattern;
|
|
841
|
+
if (pattern.includes("_"))
|
|
842
|
+
refuseQueryFilterConstruct("filter.pattern.single-character-wildcard", ctx);
|
|
843
|
+
const leading = pattern.startsWith("%");
|
|
844
|
+
const trailing = pattern.endsWith("%");
|
|
845
|
+
const core = pattern.slice(leading ? 1 : 0, trailing ? pattern.length - 1 : pattern.length);
|
|
846
|
+
if (core.includes("%"))
|
|
847
|
+
refuseQueryFilterConstruct("filter.pattern.interior-wildcard", ctx);
|
|
848
|
+
const literalText = sqlText(core);
|
|
849
|
+
if (leading && trailing)
|
|
850
|
+
return `contains(${node.operand.name},${literalText})`;
|
|
851
|
+
if (trailing)
|
|
852
|
+
return `startswith(${node.operand.name},${literalText})`;
|
|
853
|
+
if (leading)
|
|
854
|
+
return `endswith(${node.operand.name},${literalText})`;
|
|
855
|
+
return `${node.operand.name} eq ${literalText}`;
|
|
856
|
+
}
|
|
857
|
+
// ── Protocol time parameters ──────────────────────────────────
|
|
858
|
+
/**
|
|
859
|
+
* GeoServices `time=` value. Instants serialize as epoch milliseconds and
|
|
860
|
+
* intervals as `start,end`; an open bound serializes as `null`, which the
|
|
861
|
+
* FeatureServer `time` parameter defines as "unbounded on that side".
|
|
862
|
+
*/
|
|
863
|
+
export function compileTemporalFilterToGeoServicesTime(temporal, ctx) {
|
|
864
|
+
assertTemporalFilter(temporal, ctx);
|
|
865
|
+
if (temporal.kind === "instant")
|
|
866
|
+
return String(epochMillis(temporal.instant, ctx));
|
|
867
|
+
const start = temporal.start === null ? "null" : String(epochMillis(temporal.start, ctx));
|
|
868
|
+
const end = temporal.end === null ? "null" : String(epochMillis(temporal.end, ctx));
|
|
869
|
+
return `${start},${end}`;
|
|
870
|
+
}
|
|
871
|
+
/** OGC / STAC `datetime=` value (RFC 3339 instant or `start/end` interval). */
|
|
872
|
+
export function compileTemporalFilterToOgcDatetime(temporal, ctx) {
|
|
873
|
+
assertTemporalFilter(temporal, ctx);
|
|
874
|
+
if (temporal.kind === "instant")
|
|
875
|
+
return temporal.instant;
|
|
876
|
+
return `${temporal.start ?? ".."}/${temporal.end ?? ".."}`;
|
|
877
|
+
}
|
|
878
|
+
function epochMillis(instant, ctx) {
|
|
879
|
+
const millis = Date.parse(instant.length === 10 ? `${instant}T00:00:00Z` : instant);
|
|
880
|
+
if (!Number.isFinite(millis))
|
|
881
|
+
refuseQueryFilterConstruct("temporalFilter.instant", ctx);
|
|
882
|
+
return millis;
|
|
883
|
+
}
|