@nyc-transit-kit/soda3 0.1.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/LICENSE +21 -0
- package/README.md +5 -0
- package/package.json +54 -0
- package/src/catalog.ts +28 -0
- package/src/client.ts +6 -0
- package/src/endpoints.ts +98 -0
- package/src/errors.ts +51 -0
- package/src/export.ts +41 -0
- package/src/index.ts +50 -0
- package/src/internal/bodies.ts +21 -0
- package/src/internal/http.ts +42 -0
- package/src/internal/responses.ts +56 -0
- package/src/internal/services.ts +19 -0
- package/src/internal/transport.ts +87 -0
- package/src/query.ts +36 -0
- package/src/soql.ts +353 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nyc-transit-kit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nyc-transit-kit/soda3",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./catalog": "./src/catalog.ts",
|
|
9
|
+
"./client": "./src/client.ts",
|
|
10
|
+
"./endpoints": "./src/endpoints.ts",
|
|
11
|
+
"./errors": "./src/errors.ts",
|
|
12
|
+
"./export": "./src/export.ts",
|
|
13
|
+
"./query": "./src/query.ts",
|
|
14
|
+
"./soql": "./src/soql.ts"
|
|
15
|
+
},
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"README.md",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -b",
|
|
24
|
+
"test": "bun test ./test"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@nyc-transit-kit/contracts": "0.1.0",
|
|
28
|
+
"effect": "4.0.0-beta.83"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/mannyc2/nyc-transit-kit.git",
|
|
37
|
+
"directory": "packages/soda3"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/mannyc2/nyc-transit-kit#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/mannyc2/nyc-transit-kit/issues"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"bun",
|
|
45
|
+
"effect",
|
|
46
|
+
"mta",
|
|
47
|
+
"nyc",
|
|
48
|
+
"socrata",
|
|
49
|
+
"transit"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"bun": ">=1.3.14"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Soda3CatalogSearchRequestInput } from "@nyc-transit-kit/contracts/soda3"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
|
+
import { buildCatalogSearchUrl } from "./endpoints"
|
|
5
|
+
import { decodeCatalogSearchResponse, decodeJson } from "./internal/responses"
|
|
6
|
+
import { Soda3ClientConfig } from "./internal/services"
|
|
7
|
+
import { addHeaders, executeRequest, requestWithPolicy } from "./internal/transport"
|
|
8
|
+
|
|
9
|
+
export { buildCatalogSearchUrl } from "./endpoints"
|
|
10
|
+
export type { Soda3CatalogSearchRequestInput }
|
|
11
|
+
|
|
12
|
+
export const catalogSearch = Effect.fn("Soda3.catalogSearch")(function* (
|
|
13
|
+
input: Soda3CatalogSearchRequestInput
|
|
14
|
+
) {
|
|
15
|
+
const config = yield* Soda3ClientConfig
|
|
16
|
+
const url = yield* buildCatalogSearchUrl(input)
|
|
17
|
+
const request = HttpClientRequest.get(url, {
|
|
18
|
+
headers: addHeaders(config)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
return yield* requestWithPolicy(
|
|
22
|
+
"catalog",
|
|
23
|
+
executeRequest("catalog", request).pipe(
|
|
24
|
+
Effect.flatMap((response) => decodeJson("catalog", response)),
|
|
25
|
+
Effect.flatMap(decodeCatalogSearchResponse)
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
})
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { catalogSearch } from "./catalog"
|
|
2
|
+
export { exportResponse } from "./export"
|
|
3
|
+
export { Soda3HttpLive } from "./internal/http"
|
|
4
|
+
export type { Soda3ClientConfigShape } from "./internal/services"
|
|
5
|
+
export { Soda3ClientConfig, Soda3Live } from "./internal/services"
|
|
6
|
+
export { queryRows } from "./query"
|
package/src/endpoints.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Soda3CatalogSearchRequest,
|
|
3
|
+
type Soda3CatalogSearchRequestInput,
|
|
4
|
+
Soda3ExportRequest,
|
|
5
|
+
type Soda3ExportRequestInput,
|
|
6
|
+
Soda3QueryRequest,
|
|
7
|
+
type Soda3QueryRequestInput
|
|
8
|
+
} from "@nyc-transit-kit/contracts/soda3"
|
|
9
|
+
import * as Effect from "effect/Effect"
|
|
10
|
+
import * as Schema from "effect/Schema"
|
|
11
|
+
import { InvalidInputError } from "./errors"
|
|
12
|
+
|
|
13
|
+
export const socrataApiVersion = "v3"
|
|
14
|
+
export const defaultSocrataProtocol = "https"
|
|
15
|
+
export const discoveryApiHost = "api.us.socrata.com"
|
|
16
|
+
|
|
17
|
+
const decodeQueryRequest = (input: unknown) =>
|
|
18
|
+
Schema.decodeUnknownEffect(Soda3QueryRequest)(input).pipe(
|
|
19
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
20
|
+
Effect.fail(
|
|
21
|
+
InvalidInputError.make({
|
|
22
|
+
operation: "query",
|
|
23
|
+
message: error.message
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const decodeExportRequest = (input: unknown) =>
|
|
30
|
+
Schema.decodeUnknownEffect(Soda3ExportRequest)(input).pipe(
|
|
31
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
32
|
+
Effect.fail(
|
|
33
|
+
InvalidInputError.make({
|
|
34
|
+
operation: "export",
|
|
35
|
+
message: error.message
|
|
36
|
+
})
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const decodeCatalogSearchRequest = (input: unknown) =>
|
|
42
|
+
Schema.decodeUnknownEffect(Soda3CatalogSearchRequest)(input).pipe(
|
|
43
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
44
|
+
Effect.fail(
|
|
45
|
+
InvalidInputError.make({
|
|
46
|
+
operation: "catalog",
|
|
47
|
+
message: error.message
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const viewUrl = (domain: string, datasetId: string, suffix: string) => {
|
|
54
|
+
const url = new URL(`${defaultSocrataProtocol}://${domain}`)
|
|
55
|
+
url.pathname = `/api/${socrataApiVersion}/views/${datasetId}/${suffix}`
|
|
56
|
+
return url
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const buildQueryUrl = Effect.fn("Soda3.buildQueryUrl")(function* (
|
|
60
|
+
input: Soda3QueryRequestInput
|
|
61
|
+
) {
|
|
62
|
+
const request = yield* decodeQueryRequest(input)
|
|
63
|
+
return viewUrl(request.domain, request.datasetId, "query.json")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export const buildExportUrl = Effect.fn("Soda3.buildExportUrl")(function* (
|
|
67
|
+
input: Soda3ExportRequestInput
|
|
68
|
+
) {
|
|
69
|
+
const request = yield* decodeExportRequest(input)
|
|
70
|
+
return viewUrl(request.domain, request.datasetId, `export.${request.format}`)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const buildCatalogSearchUrl = Effect.fn("Soda3.buildCatalogSearchUrl")(function* (
|
|
74
|
+
input: Soda3CatalogSearchRequestInput
|
|
75
|
+
) {
|
|
76
|
+
const request = yield* decodeCatalogSearchRequest(input)
|
|
77
|
+
const url = new URL(`${defaultSocrataProtocol}://${discoveryApiHost}`)
|
|
78
|
+
url.pathname = "/api/catalog/v1"
|
|
79
|
+
url.searchParams.set("domains", request.domain)
|
|
80
|
+
url.searchParams.set("search_context", request.domain)
|
|
81
|
+
url.searchParams.set("only", "dataset")
|
|
82
|
+
|
|
83
|
+
if (request.query !== undefined) {
|
|
84
|
+
url.searchParams.set("search", request.query)
|
|
85
|
+
}
|
|
86
|
+
if (request.limit !== undefined) {
|
|
87
|
+
url.searchParams.set("limit", request.limit.toString())
|
|
88
|
+
}
|
|
89
|
+
if (request.offset !== undefined) {
|
|
90
|
+
url.searchParams.set("offset", request.offset.toString())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return url
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
export const decodeSoda3QueryRequest = decodeQueryRequest
|
|
97
|
+
export const decodeSoda3ExportRequest = decodeExportRequest
|
|
98
|
+
export const decodeSoda3CatalogSearchRequest = decodeCatalogSearchRequest
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema"
|
|
2
|
+
|
|
3
|
+
export class InvalidInputError extends Schema.TaggedErrorClass<InvalidInputError>()(
|
|
4
|
+
"InvalidInputError",
|
|
5
|
+
{
|
|
6
|
+
operation: Schema.String,
|
|
7
|
+
message: Schema.String
|
|
8
|
+
}
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
export class ProviderHttpError extends Schema.TaggedErrorClass<ProviderHttpError>()(
|
|
12
|
+
"ProviderHttpError",
|
|
13
|
+
{
|
|
14
|
+
operation: Schema.String,
|
|
15
|
+
status: Schema.Number,
|
|
16
|
+
statusText: Schema.String,
|
|
17
|
+
retryable: Schema.Boolean
|
|
18
|
+
}
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
export class ProviderContractError extends Schema.TaggedErrorClass<ProviderContractError>()(
|
|
22
|
+
"ProviderContractError",
|
|
23
|
+
{
|
|
24
|
+
operation: Schema.String,
|
|
25
|
+
message: Schema.String
|
|
26
|
+
}
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("TimeoutError", {
|
|
30
|
+
operation: Schema.String,
|
|
31
|
+
timeoutMs: Schema.Number
|
|
32
|
+
}) {}
|
|
33
|
+
|
|
34
|
+
export class RetryExhaustedError extends Schema.TaggedErrorClass<RetryExhaustedError>()(
|
|
35
|
+
"RetryExhaustedError",
|
|
36
|
+
{
|
|
37
|
+
operation: Schema.String,
|
|
38
|
+
attempts: Schema.Number,
|
|
39
|
+
message: Schema.String
|
|
40
|
+
}
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
export type Soda3ClientError =
|
|
44
|
+
| InvalidInputError
|
|
45
|
+
| ProviderHttpError
|
|
46
|
+
| ProviderContractError
|
|
47
|
+
| TimeoutError
|
|
48
|
+
| RetryExhaustedError
|
|
49
|
+
|
|
50
|
+
export const isRetryableProviderError = (error: Soda3ClientError): error is ProviderHttpError =>
|
|
51
|
+
error._tag === "ProviderHttpError" && error.retryable
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Soda3ExportRequestInput } from "@nyc-transit-kit/contracts/soda3"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
|
+
import { buildExportUrl, decodeSoda3ExportRequest } from "./endpoints"
|
|
5
|
+
import { InvalidInputError } from "./errors"
|
|
6
|
+
import { rangeHeader, toExportBody } from "./internal/bodies"
|
|
7
|
+
import { toWebResponse } from "./internal/http"
|
|
8
|
+
import { Soda3ClientConfig } from "./internal/services"
|
|
9
|
+
import { addHeaders, executeRequest, requestWithPolicy } from "./internal/transport"
|
|
10
|
+
|
|
11
|
+
export type { Soda3ExportRequestInput }
|
|
12
|
+
|
|
13
|
+
export const exportResponse = Effect.fn("Soda3.exportResponse")(function* (
|
|
14
|
+
input: Soda3ExportRequestInput
|
|
15
|
+
) {
|
|
16
|
+
const config = yield* Soda3ClientConfig
|
|
17
|
+
const exportRequest = yield* decodeSoda3ExportRequest(input)
|
|
18
|
+
const url = yield* buildExportUrl(exportRequest)
|
|
19
|
+
const headers = addHeaders(config)
|
|
20
|
+
const range = rangeHeader(exportRequest)
|
|
21
|
+
|
|
22
|
+
const request = yield* HttpClientRequest.post(url, {
|
|
23
|
+
headers: {
|
|
24
|
+
...headers,
|
|
25
|
+
range
|
|
26
|
+
}
|
|
27
|
+
}).pipe(
|
|
28
|
+
HttpClientRequest.bodyJson(toExportBody(exportRequest)),
|
|
29
|
+
Effect.mapError(() =>
|
|
30
|
+
InvalidInputError.make({
|
|
31
|
+
operation: "export",
|
|
32
|
+
message: "SODA3 export body was not JSON serializable"
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return yield* requestWithPolicy(
|
|
38
|
+
"export",
|
|
39
|
+
executeRequest("export", request).pipe(Effect.flatMap(toWebResponse))
|
|
40
|
+
)
|
|
41
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const packageName = "@nyc-transit-kit/soda3"
|
|
2
|
+
export { buildCatalogSearchUrl, catalogSearch } from "./catalog"
|
|
3
|
+
export type { Soda3ClientConfigShape } from "./client"
|
|
4
|
+
export { Soda3ClientConfig, Soda3HttpLive, Soda3Live } from "./client"
|
|
5
|
+
export {
|
|
6
|
+
buildExportUrl,
|
|
7
|
+
buildQueryUrl,
|
|
8
|
+
decodeSoda3CatalogSearchRequest,
|
|
9
|
+
decodeSoda3ExportRequest,
|
|
10
|
+
decodeSoda3QueryRequest,
|
|
11
|
+
defaultSocrataProtocol,
|
|
12
|
+
discoveryApiHost,
|
|
13
|
+
socrataApiVersion
|
|
14
|
+
} from "./endpoints"
|
|
15
|
+
export type { Soda3ClientError } from "./errors"
|
|
16
|
+
export {
|
|
17
|
+
InvalidInputError,
|
|
18
|
+
isRetryableProviderError,
|
|
19
|
+
ProviderContractError,
|
|
20
|
+
ProviderHttpError,
|
|
21
|
+
RetryExhaustedError,
|
|
22
|
+
TimeoutError
|
|
23
|
+
} from "./errors"
|
|
24
|
+
export { exportResponse } from "./export"
|
|
25
|
+
export { queryRows } from "./query"
|
|
26
|
+
export type {
|
|
27
|
+
SocrataTimestampWindow,
|
|
28
|
+
SocrataYearMonth,
|
|
29
|
+
SoqlFragment,
|
|
30
|
+
SoqlParameters,
|
|
31
|
+
SoqlSortDirection
|
|
32
|
+
} from "./soql"
|
|
33
|
+
export {
|
|
34
|
+
socrataDateWindow,
|
|
35
|
+
socrataMonthWindow,
|
|
36
|
+
socrataTimestamp,
|
|
37
|
+
soqlAnd,
|
|
38
|
+
soqlEq,
|
|
39
|
+
soqlIdentifier,
|
|
40
|
+
soqlIn,
|
|
41
|
+
soqlIsNotNull,
|
|
42
|
+
soqlLimit,
|
|
43
|
+
soqlMonthWindow,
|
|
44
|
+
soqlOrderBy,
|
|
45
|
+
soqlParameter,
|
|
46
|
+
soqlParameterName,
|
|
47
|
+
soqlSelectAll,
|
|
48
|
+
soqlTimestampRange,
|
|
49
|
+
soqlYearMonthRange
|
|
50
|
+
} from "./soql"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Soda3ExportRequest, Soda3QueryRequest } from "@nyc-transit-kit/contracts/soda3"
|
|
2
|
+
|
|
3
|
+
export const toQueryBody = (request: Soda3QueryRequest) => ({
|
|
4
|
+
query: request.query,
|
|
5
|
+
page: request.page,
|
|
6
|
+
parameters: request.parameters,
|
|
7
|
+
timeout: request.timeout,
|
|
8
|
+
includeSystem: request.includeSystem,
|
|
9
|
+
includeSynthetic: request.includeSynthetic,
|
|
10
|
+
orderingSpecifier: request.orderingSpecifier
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const toExportBody = (request: Soda3ExportRequest) => ({
|
|
14
|
+
query: request.query,
|
|
15
|
+
parameters: request.parameters,
|
|
16
|
+
timeout: request.timeout,
|
|
17
|
+
serializationOptions: request.serializationOptions
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const rangeHeader = (request: Soda3ExportRequest) =>
|
|
21
|
+
request.range === undefined ? undefined : `bytes=${request.range.start}-${request.range.end}`
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Stream from "effect/Stream"
|
|
3
|
+
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
|
|
4
|
+
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
|
|
5
|
+
import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
|
|
6
|
+
import { ProviderHttpError } from "../errors"
|
|
7
|
+
|
|
8
|
+
export const Soda3HttpLive = FetchHttpClient.layer
|
|
9
|
+
|
|
10
|
+
export const retryableStatus = (status: number) => status === 429 || status >= 500
|
|
11
|
+
|
|
12
|
+
const httpStatusText = (status: number) => `HTTP ${status}`
|
|
13
|
+
|
|
14
|
+
export const toWebResponse = (response: HttpClientResponse.HttpClientResponse) =>
|
|
15
|
+
Effect.map(
|
|
16
|
+
Stream.toReadableStreamEffect(response.stream),
|
|
17
|
+
(body) =>
|
|
18
|
+
new Response(body, {
|
|
19
|
+
status: response.status,
|
|
20
|
+
headers: response.headers
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export const mapHttpClientError = (operation: string, error: HttpClientError.HttpClientError) => {
|
|
25
|
+
const response = error.response
|
|
26
|
+
|
|
27
|
+
if (response !== undefined) {
|
|
28
|
+
return ProviderHttpError.make({
|
|
29
|
+
operation,
|
|
30
|
+
status: response.status,
|
|
31
|
+
statusText: httpStatusText(response.status),
|
|
32
|
+
retryable: retryableStatus(response.status)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return ProviderHttpError.make({
|
|
37
|
+
operation,
|
|
38
|
+
status: 0,
|
|
39
|
+
statusText: error.message,
|
|
40
|
+
retryable: true
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Soda3CatalogSearchResponse,
|
|
3
|
+
Soda3QueryResponse,
|
|
4
|
+
Soda3Row
|
|
5
|
+
} from "@nyc-transit-kit/contracts/soda3"
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Schema from "effect/Schema"
|
|
8
|
+
import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
|
|
9
|
+
import { ProviderContractError } from "../errors"
|
|
10
|
+
|
|
11
|
+
const providerRows = Schema.Array(Soda3Row)
|
|
12
|
+
|
|
13
|
+
export const decodeJson = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
|
|
14
|
+
response.json.pipe(
|
|
15
|
+
Effect.catchTag("HttpClientError", () =>
|
|
16
|
+
Effect.fail(
|
|
17
|
+
ProviderContractError.make({
|
|
18
|
+
operation,
|
|
19
|
+
message: "Provider response was not valid JSON"
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export const decodeQueryResponse = (input: unknown) =>
|
|
26
|
+
Schema.decodeUnknownEffect(Soda3QueryResponse)(input).pipe(
|
|
27
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
28
|
+
Schema.decodeUnknownEffect(providerRows)(input).pipe(
|
|
29
|
+
Effect.map((rows) =>
|
|
30
|
+
Soda3QueryResponse.make({
|
|
31
|
+
rows
|
|
32
|
+
})
|
|
33
|
+
),
|
|
34
|
+
Effect.catchTag("SchemaError", () =>
|
|
35
|
+
Effect.fail(
|
|
36
|
+
ProviderContractError.make({
|
|
37
|
+
operation: "query",
|
|
38
|
+
message: error.message
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
export const decodeCatalogSearchResponse = (input: unknown) =>
|
|
47
|
+
Schema.decodeUnknownEffect(Soda3CatalogSearchResponse)(input).pipe(
|
|
48
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
49
|
+
Effect.fail(
|
|
50
|
+
ProviderContractError.make({
|
|
51
|
+
operation: "catalog",
|
|
52
|
+
message: error.message
|
|
53
|
+
})
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as Context from "effect/Context"
|
|
2
|
+
import * as Layer from "effect/Layer"
|
|
3
|
+
import { Soda3HttpLive } from "./http"
|
|
4
|
+
|
|
5
|
+
export interface Soda3ClientConfigShape {
|
|
6
|
+
readonly appToken?: string
|
|
7
|
+
readonly retryTimes?: number
|
|
8
|
+
readonly timeoutMs?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Soda3ClientConfig extends Context.Service<Soda3ClientConfig, Soda3ClientConfigShape>()(
|
|
12
|
+
"@nyc-transit-kit/soda3/Soda3ClientConfig"
|
|
13
|
+
) {
|
|
14
|
+
static readonly Default = Layer.succeed(Soda3ClientConfig)({
|
|
15
|
+
retryTimes: 0
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Soda3Live = Layer.mergeAll(Soda3ClientConfig.Default, Soda3HttpLive)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
3
|
+
import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
|
+
import type { Soda3ClientError } from "../errors"
|
|
5
|
+
import {
|
|
6
|
+
isRetryableProviderError,
|
|
7
|
+
ProviderHttpError,
|
|
8
|
+
RetryExhaustedError,
|
|
9
|
+
TimeoutError
|
|
10
|
+
} from "../errors"
|
|
11
|
+
import { mapHttpClientError, retryableStatus } from "./http"
|
|
12
|
+
import { Soda3ClientConfig, type Soda3ClientConfigShape } from "./services"
|
|
13
|
+
|
|
14
|
+
export const addHeaders = (config: Soda3ClientConfigShape) => ({
|
|
15
|
+
accept: "application/json",
|
|
16
|
+
...(config.appToken !== undefined && config.appToken.length > 0
|
|
17
|
+
? { "X-App-Token": config.appToken }
|
|
18
|
+
: {})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export { retryableStatus }
|
|
22
|
+
|
|
23
|
+
export const requestWithPolicy = <A>(
|
|
24
|
+
operation: string,
|
|
25
|
+
effect: Effect.Effect<A, Soda3ClientError, HttpClient.HttpClient | Soda3ClientConfig>
|
|
26
|
+
) =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const config = yield* Soda3ClientConfig
|
|
29
|
+
const retryTimes = config.retryTimes ?? 0
|
|
30
|
+
const retried =
|
|
31
|
+
retryTimes > 0
|
|
32
|
+
? effect.pipe(
|
|
33
|
+
Effect.retry({
|
|
34
|
+
times: retryTimes,
|
|
35
|
+
while: isRetryableProviderError
|
|
36
|
+
}),
|
|
37
|
+
Effect.catchIf(isRetryableProviderError, (error) =>
|
|
38
|
+
Effect.fail(
|
|
39
|
+
RetryExhaustedError.make({
|
|
40
|
+
operation,
|
|
41
|
+
attempts: retryTimes + 1,
|
|
42
|
+
message: `Retry attempts exhausted after provider status ${error.status}`
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
: effect
|
|
48
|
+
|
|
49
|
+
const timeoutMs = config.timeoutMs
|
|
50
|
+
const timed =
|
|
51
|
+
timeoutMs === undefined
|
|
52
|
+
? retried
|
|
53
|
+
: retried.pipe(
|
|
54
|
+
Effect.timeoutOrElse({
|
|
55
|
+
duration: timeoutMs,
|
|
56
|
+
orElse: () =>
|
|
57
|
+
Effect.fail(
|
|
58
|
+
TimeoutError.make({
|
|
59
|
+
operation,
|
|
60
|
+
timeoutMs
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return yield* timed
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const httpStatusText = (status: number) => `HTTP ${status}`
|
|
70
|
+
|
|
71
|
+
export const executeRequest = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const response = yield* HttpClient.execute(request).pipe(
|
|
74
|
+
Effect.mapError((error) => mapHttpClientError(operation, error))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if (response.status < 200 || response.status >= 300) {
|
|
78
|
+
return yield* ProviderHttpError.make({
|
|
79
|
+
operation,
|
|
80
|
+
status: response.status,
|
|
81
|
+
statusText: httpStatusText(response.status),
|
|
82
|
+
retryable: retryableStatus(response.status)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return response
|
|
87
|
+
})
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Soda3QueryRequestInput } from "@nyc-transit-kit/contracts/soda3"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
|
+
import { buildQueryUrl, decodeSoda3QueryRequest } from "./endpoints"
|
|
5
|
+
import { InvalidInputError } from "./errors"
|
|
6
|
+
import { toQueryBody } from "./internal/bodies"
|
|
7
|
+
import { decodeJson, decodeQueryResponse } from "./internal/responses"
|
|
8
|
+
import { Soda3ClientConfig } from "./internal/services"
|
|
9
|
+
import { addHeaders, executeRequest, requestWithPolicy } from "./internal/transport"
|
|
10
|
+
|
|
11
|
+
export type { Soda3QueryRequestInput }
|
|
12
|
+
|
|
13
|
+
export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3QueryRequestInput) {
|
|
14
|
+
const config = yield* Soda3ClientConfig
|
|
15
|
+
const queryRequest = yield* decodeSoda3QueryRequest(input)
|
|
16
|
+
const url = yield* buildQueryUrl(queryRequest)
|
|
17
|
+
const request = yield* HttpClientRequest.post(url, {
|
|
18
|
+
headers: addHeaders(config)
|
|
19
|
+
}).pipe(
|
|
20
|
+
HttpClientRequest.bodyJson(toQueryBody(queryRequest)),
|
|
21
|
+
Effect.mapError(() =>
|
|
22
|
+
InvalidInputError.make({
|
|
23
|
+
operation: "query",
|
|
24
|
+
message: "SODA3 query body was not JSON serializable"
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return yield* requestWithPolicy(
|
|
30
|
+
"query",
|
|
31
|
+
executeRequest("query", request).pipe(
|
|
32
|
+
Effect.flatMap((response) => decodeJson("query", response)),
|
|
33
|
+
Effect.flatMap(decodeQueryResponse)
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
})
|
package/src/soql.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { IsoDate } from "@nyc-transit-kit/contracts/ids"
|
|
2
|
+
import { PositiveInteger } from "@nyc-transit-kit/contracts/soda3"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Schema from "effect/Schema"
|
|
5
|
+
import { InvalidInputError } from "./errors"
|
|
6
|
+
|
|
7
|
+
export const soqlSelectAll = "SELECT *"
|
|
8
|
+
|
|
9
|
+
export type SoqlParameters = Readonly<Record<string, unknown>>
|
|
10
|
+
|
|
11
|
+
export type SoqlFragment = {
|
|
12
|
+
readonly text: string
|
|
13
|
+
readonly parameters?: SoqlParameters
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SocrataTimestampWindow = {
|
|
17
|
+
readonly start: string
|
|
18
|
+
readonly end: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SocrataYearMonth = {
|
|
22
|
+
readonly year: number
|
|
23
|
+
readonly month: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const soqlIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
27
|
+
const soqlParameterNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
28
|
+
const isoDatePartsPattern = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
29
|
+
|
|
30
|
+
const SoqlIdentifier = Schema.String.pipe(
|
|
31
|
+
Schema.check(
|
|
32
|
+
Schema.isPattern(soqlIdentifierPattern, {
|
|
33
|
+
message: "Expected a SoQL identifier such as route_id"
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const SoqlParameterName = Schema.String.pipe(
|
|
39
|
+
Schema.check(
|
|
40
|
+
Schema.isPattern(soqlParameterNamePattern, {
|
|
41
|
+
message: "Expected a SoQL parameter name such as route"
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const SoqlSortDirection = Schema.Literals(["ASC", "DESC"])
|
|
47
|
+
export type SoqlSortDirection = typeof SoqlSortDirection.Type
|
|
48
|
+
|
|
49
|
+
const SocrataYear = Schema.Int.pipe(
|
|
50
|
+
Schema.check(Schema.isGreaterThanOrEqualTo(1900)),
|
|
51
|
+
Schema.check(Schema.isLessThanOrEqualTo(9999))
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const SocrataMonth = Schema.Int.pipe(
|
|
55
|
+
Schema.check(Schema.isGreaterThanOrEqualTo(1)),
|
|
56
|
+
Schema.check(Schema.isLessThanOrEqualTo(12))
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const SocrataYearMonthSchema = Schema.Struct({
|
|
60
|
+
year: SocrataYear,
|
|
61
|
+
month: SocrataMonth
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const invalidQueryInput = (message: string) =>
|
|
65
|
+
InvalidInputError.make({
|
|
66
|
+
operation: "query",
|
|
67
|
+
message
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const decodeQueryInput =
|
|
71
|
+
<S extends Schema.Top>(schema: S) =>
|
|
72
|
+
(input: unknown) =>
|
|
73
|
+
Schema.decodeUnknownEffect(schema)(input).pipe(
|
|
74
|
+
Effect.catchTag("SchemaError", (error) => Effect.fail(invalidQueryInput(error.message)))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const decodeLimit = decodeQueryInput(PositiveInteger)
|
|
78
|
+
const decodeIdentifier = decodeQueryInput(SoqlIdentifier)
|
|
79
|
+
const decodeParameterName = decodeQueryInput(SoqlParameterName)
|
|
80
|
+
const decodeSortDirection = decodeQueryInput(SoqlSortDirection)
|
|
81
|
+
const decodeIsoDate = decodeQueryInput(IsoDate)
|
|
82
|
+
const decodeYear = decodeQueryInput(SocrataYear)
|
|
83
|
+
const decodeMonth = decodeQueryInput(SocrataMonth)
|
|
84
|
+
const decodeYearMonth = decodeQueryInput(SocrataYearMonthSchema)
|
|
85
|
+
|
|
86
|
+
const withParameters = (
|
|
87
|
+
text: string,
|
|
88
|
+
parameters: Readonly<Record<string, unknown>>
|
|
89
|
+
): SoqlFragment => ({
|
|
90
|
+
text,
|
|
91
|
+
parameters
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const withoutParameters = (text: string): SoqlFragment => ({
|
|
95
|
+
text
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const mergeParameters = (fragments: ReadonlyArray<SoqlFragment>) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const parameters: Record<string, unknown> = {}
|
|
101
|
+
|
|
102
|
+
for (const fragment of fragments) {
|
|
103
|
+
for (const [key, value] of Object.entries(fragment.parameters ?? {})) {
|
|
104
|
+
if (Object.hasOwn(parameters, key)) {
|
|
105
|
+
return yield* invalidQueryInput(`Duplicate SoQL parameter name: ${key}`)
|
|
106
|
+
}
|
|
107
|
+
parameters[key] = value
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Object.keys(parameters).length === 0 ? undefined : parameters
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const pad2 = (value: number) => value.toString().padStart(2, "0")
|
|
115
|
+
|
|
116
|
+
const timestampFromParts = (year: number, month: number, day: number) =>
|
|
117
|
+
`${year.toString().padStart(4, "0")}-${pad2(month)}-${pad2(day)}T00:00:00`
|
|
118
|
+
|
|
119
|
+
const dateParts = (value: string) =>
|
|
120
|
+
Effect.gen(function* () {
|
|
121
|
+
const match = isoDatePartsPattern.exec(value)
|
|
122
|
+
if (match === null) {
|
|
123
|
+
return yield* invalidQueryInput("Expected an ISO calendar date in YYYY-MM-DD format")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const yearText = match[1]
|
|
127
|
+
const monthText = match[2]
|
|
128
|
+
const dayText = match[3]
|
|
129
|
+
if (yearText === undefined || monthText === undefined || dayText === undefined) {
|
|
130
|
+
return yield* invalidQueryInput("Expected an ISO calendar date in YYYY-MM-DD format")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
year: Number(yearText),
|
|
135
|
+
month: Number(monthText),
|
|
136
|
+
day: Number(dayText)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const utcTimestampFromDate = (date: Date) =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
if (Number.isNaN(date.getTime())) {
|
|
143
|
+
return yield* invalidQueryInput("Expected a valid Date")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return timestampFromParts(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate())
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const epochDay = (value: string) =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const parts = yield* dateParts(value)
|
|
152
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
export const soqlLimit = Effect.fn("Soda3.soqlLimit")(function* (query: string, limit: unknown) {
|
|
156
|
+
const decodedLimit = yield* decodeLimit(limit)
|
|
157
|
+
return `${query} LIMIT ${decodedLimit}`
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
export const soqlIdentifier = Effect.fn("Soda3.soqlIdentifier")(function* (input: unknown) {
|
|
161
|
+
return yield* decodeIdentifier(input)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
export const soqlParameterName = Effect.fn("Soda3.soqlParameterName")(function* (input: unknown) {
|
|
165
|
+
return yield* decodeParameterName(input)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
export const soqlParameter = Effect.fn("Soda3.soqlParameter")(function* (
|
|
169
|
+
name: unknown,
|
|
170
|
+
value: unknown
|
|
171
|
+
) {
|
|
172
|
+
const decodedName = yield* decodeParameterName(name)
|
|
173
|
+
return withParameters(`:${decodedName}`, {
|
|
174
|
+
[decodedName]: value
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
export const soqlEq = Effect.fn("Soda3.soqlEq")(function* (
|
|
179
|
+
column: unknown,
|
|
180
|
+
parameterName: unknown,
|
|
181
|
+
value: unknown
|
|
182
|
+
) {
|
|
183
|
+
const decodedColumn = yield* decodeIdentifier(column)
|
|
184
|
+
const decodedParameterName = yield* decodeParameterName(parameterName)
|
|
185
|
+
|
|
186
|
+
return withParameters(`${decodedColumn} = :${decodedParameterName}`, {
|
|
187
|
+
[decodedParameterName]: value
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
export const soqlIn = Effect.fn("Soda3.soqlIn")(function* (
|
|
192
|
+
column: unknown,
|
|
193
|
+
parameterBaseName: unknown,
|
|
194
|
+
values: ReadonlyArray<unknown>
|
|
195
|
+
) {
|
|
196
|
+
const decodedColumn = yield* decodeIdentifier(column)
|
|
197
|
+
const decodedParameterBaseName = yield* decodeParameterName(parameterBaseName)
|
|
198
|
+
|
|
199
|
+
if (values.length === 0) {
|
|
200
|
+
return yield* invalidQueryInput("SoQL IN predicates require at least one value")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const parameters: Record<string, unknown> = {}
|
|
204
|
+
const placeholders: Array<string> = []
|
|
205
|
+
|
|
206
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
207
|
+
const parameterName = `${decodedParameterBaseName}_${index + 1}`
|
|
208
|
+
parameters[parameterName] = values[index]
|
|
209
|
+
placeholders.push(`:${parameterName}`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return withParameters(`${decodedColumn} IN (${placeholders.join(", ")})`, parameters)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
export const soqlIsNotNull = Effect.fn("Soda3.soqlIsNotNull")(function* (column: unknown) {
|
|
216
|
+
const decodedColumn = yield* decodeIdentifier(column)
|
|
217
|
+
return withoutParameters(`${decodedColumn} IS NOT NULL`)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
export const soqlAnd = Effect.fn("Soda3.soqlAnd")(function* (
|
|
221
|
+
fragments: ReadonlyArray<SoqlFragment>
|
|
222
|
+
) {
|
|
223
|
+
if (fragments.length === 0) {
|
|
224
|
+
return yield* invalidQueryInput("SoQL AND composition requires at least one fragment")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const parameters = yield* mergeParameters(fragments)
|
|
228
|
+
const text = fragments.map((fragment) => `(${fragment.text})`).join(" AND ")
|
|
229
|
+
|
|
230
|
+
return parameters === undefined ? withoutParameters(text) : withParameters(text, parameters)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
export const soqlOrderBy = Effect.fn("Soda3.soqlOrderBy")(function* (
|
|
234
|
+
column: unknown,
|
|
235
|
+
direction: unknown = "ASC"
|
|
236
|
+
) {
|
|
237
|
+
const decodedColumn = yield* decodeIdentifier(column)
|
|
238
|
+
const decodedDirection = yield* decodeSortDirection(direction)
|
|
239
|
+
return withoutParameters(`ORDER BY ${decodedColumn} ${decodedDirection}`)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
export const socrataTimestamp = Effect.fn("Soda3.socrataTimestamp")(function* (
|
|
243
|
+
date: string | Date
|
|
244
|
+
) {
|
|
245
|
+
if (date instanceof Date) {
|
|
246
|
+
return yield* utcTimestampFromDate(date)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const decodedDate = yield* decodeIsoDate(date)
|
|
250
|
+
const parts = yield* dateParts(decodedDate)
|
|
251
|
+
return timestampFromParts(parts.year, parts.month, parts.day)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
export const socrataMonthWindow = Effect.fn("Soda3.socrataMonthWindow")(function* (
|
|
255
|
+
year: unknown,
|
|
256
|
+
month: unknown
|
|
257
|
+
) {
|
|
258
|
+
const decodedYear = yield* decodeYear(year)
|
|
259
|
+
const decodedMonth = yield* decodeMonth(month)
|
|
260
|
+
const endYear = decodedMonth === 12 ? decodedYear + 1 : decodedYear
|
|
261
|
+
const endMonth = decodedMonth === 12 ? 1 : decodedMonth + 1
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
start: timestampFromParts(decodedYear, decodedMonth, 1),
|
|
265
|
+
end: timestampFromParts(endYear, endMonth, 1)
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
export const socrataDateWindow = Effect.fn("Soda3.socrataDateWindow")(function* (
|
|
270
|
+
startDate: unknown,
|
|
271
|
+
endDate: unknown
|
|
272
|
+
) {
|
|
273
|
+
const decodedStartDate = yield* decodeIsoDate(startDate)
|
|
274
|
+
const decodedEndDate = yield* decodeIsoDate(endDate)
|
|
275
|
+
const startEpochDay = yield* epochDay(decodedStartDate)
|
|
276
|
+
const endEpochDay = yield* epochDay(decodedEndDate)
|
|
277
|
+
|
|
278
|
+
if (startEpochDay >= endEpochDay) {
|
|
279
|
+
return yield* invalidQueryInput("Socrata date windows require startDate before endDate")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
start: yield* socrataTimestamp(decodedStartDate),
|
|
284
|
+
end: yield* socrataTimestamp(decodedEndDate)
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
export const soqlTimestampRange = Effect.fn("Soda3.soqlTimestampRange")(function* (
|
|
289
|
+
column: unknown,
|
|
290
|
+
startParameterName: unknown,
|
|
291
|
+
endParameterName: unknown,
|
|
292
|
+
window: SocrataTimestampWindow
|
|
293
|
+
) {
|
|
294
|
+
const decodedColumn = yield* decodeIdentifier(column)
|
|
295
|
+
const decodedStartParameterName = yield* decodeParameterName(startParameterName)
|
|
296
|
+
const decodedEndParameterName = yield* decodeParameterName(endParameterName)
|
|
297
|
+
|
|
298
|
+
if (decodedStartParameterName === decodedEndParameterName) {
|
|
299
|
+
return yield* invalidQueryInput(`Duplicate SoQL parameter name: ${decodedStartParameterName}`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return withParameters(
|
|
303
|
+
`${decodedColumn} >= :${decodedStartParameterName} AND ${decodedColumn} < :${decodedEndParameterName}`,
|
|
304
|
+
{
|
|
305
|
+
[decodedStartParameterName]: window.start,
|
|
306
|
+
[decodedEndParameterName]: window.end
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
export const soqlMonthWindow = Effect.fn("Soda3.soqlMonthWindow")(function* (
|
|
312
|
+
column: unknown,
|
|
313
|
+
year: unknown,
|
|
314
|
+
month: unknown,
|
|
315
|
+
parameterBaseName: unknown
|
|
316
|
+
) {
|
|
317
|
+
const decodedParameterBaseName = yield* decodeParameterName(parameterBaseName)
|
|
318
|
+
const window = yield* socrataMonthWindow(year, month)
|
|
319
|
+
return yield* soqlTimestampRange(
|
|
320
|
+
column,
|
|
321
|
+
`${decodedParameterBaseName}_start`,
|
|
322
|
+
`${decodedParameterBaseName}_end`,
|
|
323
|
+
window
|
|
324
|
+
)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
export const soqlYearMonthRange = Effect.fn("Soda3.soqlYearMonthRange")(function* (
|
|
328
|
+
yearColumn: unknown,
|
|
329
|
+
monthColumn: unknown,
|
|
330
|
+
start: unknown,
|
|
331
|
+
end: unknown
|
|
332
|
+
) {
|
|
333
|
+
const decodedYearColumn = yield* decodeIdentifier(yearColumn)
|
|
334
|
+
const decodedMonthColumn = yield* decodeIdentifier(monthColumn)
|
|
335
|
+
const decodedStart = yield* decodeYearMonth(start)
|
|
336
|
+
const decodedEnd = yield* decodeYearMonth(end)
|
|
337
|
+
const startOrder = decodedStart.year * 12 + decodedStart.month
|
|
338
|
+
const endOrder = decodedEnd.year * 12 + decodedEnd.month
|
|
339
|
+
|
|
340
|
+
if (startOrder > endOrder) {
|
|
341
|
+
return yield* invalidQueryInput("SoQL year/month ranges require start before end")
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return withParameters(
|
|
345
|
+
`(${decodedYearColumn} > :start_year OR (${decodedYearColumn} = :start_year AND ${decodedMonthColumn} >= :start_month)) AND (${decodedYearColumn} < :end_year OR (${decodedYearColumn} = :end_year AND ${decodedMonthColumn} <= :end_month))`,
|
|
346
|
+
{
|
|
347
|
+
start_year: decodedStart.year,
|
|
348
|
+
start_month: decodedStart.month,
|
|
349
|
+
end_year: decodedEnd.year,
|
|
350
|
+
end_month: decodedEnd.month
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
})
|