@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.
@@ -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
- })