@nyc-transit-kit/soda3 0.1.4 → 0.3.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/README.md +38 -21
- package/package.json +13 -17
- package/src/Dataset.ts +72 -0
- package/src/Discovery.ts +107 -0
- package/src/Socrata.ts +115 -0
- package/src/Soda3Client.ts +224 -0
- package/src/Soql.ts +200 -0
- package/src/index.ts +5 -61
- package/src/catalog.ts +0 -28
- package/src/client.ts +0 -17
- package/src/endpoints.ts +0 -110
- package/src/errors.ts +0 -58
- package/src/export.ts +0 -41
- package/src/internal/bodies.ts +0 -23
- package/src/internal/http.ts +0 -42
- package/src/internal/responses.ts +0 -56
- package/src/internal/services.ts +0 -48
- package/src/internal/transport.ts +0 -99
- package/src/metadata.ts +0 -47
- package/src/query.ts +0 -132
- package/src/soql.ts +0 -353
package/src/internal/services.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import * as Context from "effect/Context"
|
|
2
|
-
import * as Layer from "effect/Layer"
|
|
3
|
-
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
|
|
4
|
-
import { Soda3HttpLive } from "./http"
|
|
5
|
-
|
|
6
|
-
export interface Soda3ClientConfigShape {
|
|
7
|
-
readonly appToken?: string
|
|
8
|
-
readonly retryTimes?: number
|
|
9
|
-
readonly retryDelayMs?: number
|
|
10
|
-
readonly timeoutMs?: number
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export type FetchImplementation = (
|
|
14
|
-
input: string | URL | globalThis.Request,
|
|
15
|
-
init?: globalThis.RequestInit
|
|
16
|
-
) => Promise<globalThis.Response>
|
|
17
|
-
|
|
18
|
-
export interface Soda3LayerOptions extends Soda3ClientConfigShape {
|
|
19
|
-
readonly fetch?: FetchImplementation
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class Soda3ClientConfig extends Context.Service<Soda3ClientConfig, Soda3ClientConfigShape>()(
|
|
23
|
-
"@nyc-transit-kit/soda3/Soda3ClientConfig"
|
|
24
|
-
) {
|
|
25
|
-
static readonly Default = Layer.succeed(Soda3ClientConfig)({
|
|
26
|
-
retryTimes: 0
|
|
27
|
-
})
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const soda3ConfigLayer = (options: Soda3ClientConfigShape = {}) =>
|
|
31
|
-
Layer.succeed(Soda3ClientConfig)({
|
|
32
|
-
retryTimes: options.retryTimes ?? 0,
|
|
33
|
-
...(options.appToken === undefined ? {} : { appToken: options.appToken }),
|
|
34
|
-
...(options.retryDelayMs === undefined ? {} : { retryDelayMs: options.retryDelayMs }),
|
|
35
|
-
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs })
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
export const soda3FetchLayer = (fetchImplementation?: FetchImplementation) =>
|
|
39
|
-
fetchImplementation === undefined
|
|
40
|
-
? Soda3HttpLive
|
|
41
|
-
: FetchHttpClient.layer.pipe(
|
|
42
|
-
Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImplementation as typeof fetch))
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
export const soda3Layer = (options: Soda3LayerOptions = {}) =>
|
|
46
|
-
Layer.mergeAll(soda3ConfigLayer(options), soda3FetchLayer(options.fetch))
|
|
47
|
-
|
|
48
|
-
export const Soda3Live = soda3Layer()
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import * as Duration from "effect/Duration"
|
|
2
|
-
import * as Effect from "effect/Effect"
|
|
3
|
-
import * as Schedule from "effect/Schedule"
|
|
4
|
-
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
5
|
-
import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
6
|
-
import type { Soda3ClientError } from "../errors"
|
|
7
|
-
import {
|
|
8
|
-
isRetryableProviderError,
|
|
9
|
-
ProviderHttpError,
|
|
10
|
-
RetryExhaustedError,
|
|
11
|
-
TimeoutError
|
|
12
|
-
} from "../errors"
|
|
13
|
-
import { mapHttpClientError, retryableStatus } from "./http"
|
|
14
|
-
import { Soda3ClientConfig, type Soda3ClientConfigShape } from "./services"
|
|
15
|
-
|
|
16
|
-
export const addHeaders = (config: Soda3ClientConfigShape) => ({
|
|
17
|
-
accept: "application/json",
|
|
18
|
-
...(config.appToken !== undefined && config.appToken.length > 0
|
|
19
|
-
? { "X-App-Token": config.appToken }
|
|
20
|
-
: {})
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
export { retryableStatus }
|
|
24
|
-
|
|
25
|
-
const retryPolicy = (retryTimes: number, retryDelayMs: number | undefined) =>
|
|
26
|
-
retryDelayMs === undefined || retryDelayMs <= 0
|
|
27
|
-
? {
|
|
28
|
-
times: retryTimes,
|
|
29
|
-
while: isRetryableProviderError
|
|
30
|
-
}
|
|
31
|
-
: {
|
|
32
|
-
schedule: Schedule.exponential(Duration.millis(retryDelayMs)).pipe(
|
|
33
|
-
Schedule.both(Schedule.recurs(retryTimes))
|
|
34
|
-
),
|
|
35
|
-
while: isRetryableProviderError
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const requestWithPolicy = <A>(
|
|
39
|
-
operation: string,
|
|
40
|
-
effect: Effect.Effect<A, Soda3ClientError, HttpClient.HttpClient | Soda3ClientConfig>
|
|
41
|
-
) =>
|
|
42
|
-
Effect.gen(function* () {
|
|
43
|
-
const config = yield* Soda3ClientConfig
|
|
44
|
-
const retryTimes = config.retryTimes ?? 0
|
|
45
|
-
const retried =
|
|
46
|
-
retryTimes > 0
|
|
47
|
-
? effect.pipe(
|
|
48
|
-
Effect.retry(retryPolicy(retryTimes, config.retryDelayMs)),
|
|
49
|
-
Effect.catchIf(isRetryableProviderError, (error) =>
|
|
50
|
-
Effect.fail(
|
|
51
|
-
RetryExhaustedError.make({
|
|
52
|
-
operation,
|
|
53
|
-
attempts: retryTimes + 1,
|
|
54
|
-
message: `Retry attempts exhausted after provider status ${error.status}`
|
|
55
|
-
})
|
|
56
|
-
)
|
|
57
|
-
)
|
|
58
|
-
)
|
|
59
|
-
: effect
|
|
60
|
-
|
|
61
|
-
const timeoutMs = config.timeoutMs
|
|
62
|
-
const timed =
|
|
63
|
-
timeoutMs === undefined
|
|
64
|
-
? retried
|
|
65
|
-
: retried.pipe(
|
|
66
|
-
Effect.timeoutOrElse({
|
|
67
|
-
duration: timeoutMs,
|
|
68
|
-
orElse: () =>
|
|
69
|
-
Effect.fail(
|
|
70
|
-
TimeoutError.make({
|
|
71
|
-
operation,
|
|
72
|
-
timeoutMs
|
|
73
|
-
})
|
|
74
|
-
)
|
|
75
|
-
})
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
return yield* timed
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
const httpStatusText = (status: number) => `HTTP ${status}`
|
|
82
|
-
|
|
83
|
-
export const executeRequest = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
|
84
|
-
Effect.gen(function* () {
|
|
85
|
-
const response = yield* HttpClient.execute(request).pipe(
|
|
86
|
-
Effect.mapError((error) => mapHttpClientError(operation, error))
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
if (response.status < 200 || response.status >= 300) {
|
|
90
|
-
return yield* ProviderHttpError.make({
|
|
91
|
-
operation,
|
|
92
|
-
status: response.status,
|
|
93
|
-
statusText: httpStatusText(response.status),
|
|
94
|
-
retryable: retryableStatus(response.status)
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return response
|
|
99
|
-
})
|
package/src/metadata.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type Soda3CatalogResultFragment,
|
|
3
|
-
Soda3DatasetMetadataRequest,
|
|
4
|
-
type Soda3DatasetMetadataRequestInput
|
|
5
|
-
} from "@nyc-transit-kit/contracts/soda3"
|
|
6
|
-
import * as Effect from "effect/Effect"
|
|
7
|
-
import * as Schema from "effect/Schema"
|
|
8
|
-
import { catalogSearch } from "./catalog"
|
|
9
|
-
import { InvalidInputError, ProviderContractError } from "./errors"
|
|
10
|
-
|
|
11
|
-
export type { Soda3DatasetMetadataRequestInput }
|
|
12
|
-
|
|
13
|
-
const decodeDatasetMetadataRequest = (input: unknown) =>
|
|
14
|
-
Schema.decodeUnknownEffect(Soda3DatasetMetadataRequest)(input).pipe(
|
|
15
|
-
Effect.catchTag("SchemaError", (error) =>
|
|
16
|
-
Effect.fail(
|
|
17
|
-
InvalidInputError.make({
|
|
18
|
-
operation: "metadata",
|
|
19
|
-
message: error.message
|
|
20
|
-
})
|
|
21
|
-
)
|
|
22
|
-
)
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
const matchingResult = (results: ReadonlyArray<Soda3CatalogResultFragment>, datasetId: string) =>
|
|
26
|
-
results.find((result) => String(result.resource.id) === datasetId)
|
|
27
|
-
|
|
28
|
-
export const fetchDatasetMetadata = Effect.fn("Soda3.fetchDatasetMetadata")(function* (
|
|
29
|
-
input: Soda3DatasetMetadataRequestInput
|
|
30
|
-
) {
|
|
31
|
-
const request = yield* decodeDatasetMetadataRequest(input)
|
|
32
|
-
const response = yield* catalogSearch({
|
|
33
|
-
domain: request.domain,
|
|
34
|
-
ids: [request.datasetId],
|
|
35
|
-
limit: 1
|
|
36
|
-
})
|
|
37
|
-
const result = matchingResult(response.results, String(request.datasetId))
|
|
38
|
-
|
|
39
|
-
if (result === undefined) {
|
|
40
|
-
return yield* ProviderContractError.make({
|
|
41
|
-
operation: "metadata",
|
|
42
|
-
message: `Discovery metadata response did not include dataset ${request.datasetId}`
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return result
|
|
47
|
-
})
|
package/src/query.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
PositiveInteger,
|
|
3
|
-
type Soda3QueryRequestInput,
|
|
4
|
-
type Soda3Row
|
|
5
|
-
} from "@nyc-transit-kit/contracts/soda3"
|
|
6
|
-
import * as Effect from "effect/Effect"
|
|
7
|
-
import * as Schema from "effect/Schema"
|
|
8
|
-
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
9
|
-
import { buildQueryUrl, decodeSoda3QueryRequest } from "./endpoints"
|
|
10
|
-
import { InvalidInputError, ProviderContractError } from "./errors"
|
|
11
|
-
import { toQueryBody } from "./internal/bodies"
|
|
12
|
-
import { decodeJson, decodeQueryResponse } from "./internal/responses"
|
|
13
|
-
import { Soda3ClientConfig } from "./internal/services"
|
|
14
|
-
import { addHeaders, executeRequest, requestWithPolicy } from "./internal/transport"
|
|
15
|
-
|
|
16
|
-
export type { Soda3QueryRequestInput }
|
|
17
|
-
|
|
18
|
-
export type Soda3QueryAllRowsInput = Omit<Soda3QueryRequestInput, "page"> & {
|
|
19
|
-
readonly pageSize?: number
|
|
20
|
-
readonly maxRows?: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type Soda3CountRowsInput = {
|
|
24
|
-
readonly domain: string
|
|
25
|
-
readonly datasetId: string
|
|
26
|
-
readonly where?: string
|
|
27
|
-
readonly parameters?: Readonly<Record<string, unknown>>
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const decodeHelperPositiveInteger = (name: string, value: number) =>
|
|
31
|
-
Schema.decodeUnknownEffect(PositiveInteger)(value).pipe(
|
|
32
|
-
Effect.catchTag("SchemaError", (error) =>
|
|
33
|
-
Effect.fail(
|
|
34
|
-
InvalidInputError.make({
|
|
35
|
-
operation: "query",
|
|
36
|
-
message: `Invalid ${name}: ${error.message}`
|
|
37
|
-
})
|
|
38
|
-
)
|
|
39
|
-
)
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
const decodeOptionalHelperPositiveInteger = (name: string, value: number | undefined) =>
|
|
43
|
-
value === undefined ? Effect.void : decodeHelperPositiveInteger(name, value)
|
|
44
|
-
|
|
45
|
-
const decodeCountValue = (value: unknown) => {
|
|
46
|
-
const parsed =
|
|
47
|
-
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN
|
|
48
|
-
|
|
49
|
-
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const countColumnName = "count"
|
|
53
|
-
|
|
54
|
-
export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3QueryRequestInput) {
|
|
55
|
-
const config = yield* Soda3ClientConfig
|
|
56
|
-
const queryRequest = yield* decodeSoda3QueryRequest(input)
|
|
57
|
-
const url = yield* buildQueryUrl(queryRequest)
|
|
58
|
-
const request = yield* HttpClientRequest.post(url, {
|
|
59
|
-
headers: addHeaders(config)
|
|
60
|
-
}).pipe(
|
|
61
|
-
HttpClientRequest.bodyJson(toQueryBody(queryRequest)),
|
|
62
|
-
Effect.mapError(() =>
|
|
63
|
-
InvalidInputError.make({
|
|
64
|
-
operation: "query",
|
|
65
|
-
message: "SODA3 query body was not JSON serializable"
|
|
66
|
-
})
|
|
67
|
-
)
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
return yield* requestWithPolicy(
|
|
71
|
-
"query",
|
|
72
|
-
executeRequest("query", request).pipe(
|
|
73
|
-
Effect.flatMap((response) => decodeJson("query", response)),
|
|
74
|
-
Effect.flatMap(decodeQueryResponse)
|
|
75
|
-
)
|
|
76
|
-
)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
export const queryAllRows = Effect.fn("Soda3.queryAllRows")(function* (
|
|
80
|
-
input: Soda3QueryAllRowsInput
|
|
81
|
-
) {
|
|
82
|
-
const { pageSize: rawPageSize = 1000, maxRows: rawMaxRows, ...queryInput } = input
|
|
83
|
-
const pageSize = yield* decodeHelperPositiveInteger("pageSize", rawPageSize)
|
|
84
|
-
const maxRows = yield* decodeOptionalHelperPositiveInteger("maxRows", rawMaxRows)
|
|
85
|
-
const rows: Array<Soda3Row> = []
|
|
86
|
-
let pageNumber = 1
|
|
87
|
-
|
|
88
|
-
while (true) {
|
|
89
|
-
const response = yield* queryRows({
|
|
90
|
-
...queryInput,
|
|
91
|
-
page: {
|
|
92
|
-
pageNumber,
|
|
93
|
-
pageSize
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
rows.push(...response.rows)
|
|
97
|
-
|
|
98
|
-
if (maxRows !== undefined && rows.length > maxRows) {
|
|
99
|
-
return yield* InvalidInputError.make({
|
|
100
|
-
operation: "query",
|
|
101
|
-
message: `SODA3 query exceeded maxRows=${maxRows}; narrow the query or raise maxRows`
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (response.rows.length < pageSize) {
|
|
106
|
-
return rows
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
pageNumber += 1
|
|
110
|
-
}
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
export const countRows = Effect.fn("Soda3.countRows")(function* (input: Soda3CountRowsInput) {
|
|
114
|
-
const query = `SELECT count(*)${input.where === undefined ? "" : ` WHERE ${input.where}`}`
|
|
115
|
-
const response = yield* queryRows({
|
|
116
|
-
domain: input.domain,
|
|
117
|
-
datasetId: input.datasetId,
|
|
118
|
-
query,
|
|
119
|
-
...(input.parameters === undefined ? {} : { parameters: input.parameters }),
|
|
120
|
-
includeSynthetic: false
|
|
121
|
-
})
|
|
122
|
-
const count = decodeCountValue(response.rows[0]?.[countColumnName])
|
|
123
|
-
|
|
124
|
-
if (count === undefined) {
|
|
125
|
-
return yield* ProviderContractError.make({
|
|
126
|
-
operation: "query",
|
|
127
|
-
message: "SODA3 count(*) response did not include a numeric count"
|
|
128
|
-
})
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return count
|
|
132
|
-
})
|
package/src/soql.ts
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
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
|
-
})
|