@leuduan/block 0.0.0-stage → 3.0.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 +15 -0
- package/README.md +84 -2
- package/dist/build.d.ts +40 -0
- package/dist/build.js +171 -0
- package/dist/define.d.ts +8 -0
- package/dist/define.js +25 -0
- package/dist/descriptor.d.ts +66 -0
- package/dist/descriptor.js +19 -0
- package/dist/expr.d.ts +37 -0
- package/dist/expr.js +159 -0
- package/dist/helpers.d.ts +28 -0
- package/dist/helpers.js +98 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +19 -0
- package/dist/line.d.ts +79 -0
- package/dist/line.js +450 -0
- package/dist/pipe.d.ts +39 -0
- package/dist/pipe.js +106 -0
- package/dist/problems.d.ts +7 -0
- package/dist/problems.js +129 -0
- package/dist/query.d.ts +37 -0
- package/dist/query.js +164 -0
- package/dist/sql.d.ts +72 -0
- package/dist/sql.js +169 -0
- package/dist/t.d.ts +8 -0
- package/dist/t.js +17 -0
- package/package.json +36 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Duan Leu
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose
|
|
6
|
+
with or without fee is hereby granted, provided that the above copyright notice
|
|
7
|
+
and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @leuduan/block
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SQL builder and renderer for GoogleSQL, in pipe syntax (`FROM t |> WHERE ...`) with standard `SELECT` available
|
|
4
|
+
for CTEs and subqueries. TypeScript port of `dbox.block` (`py/dbox/dbox/block`). Pure functions over plain data:
|
|
5
|
+
no I/O, no credentials, no dependencies. Rendered SQL text is the package's contract; it is not required to match
|
|
6
|
+
the Python builder byte for byte.
|
|
7
|
+
|
|
8
|
+
## Ownership
|
|
9
|
+
|
|
10
|
+
The package owns the immutable query model (`QueryExpression`, `Pipe`, `TableExpression`), the line-breaking
|
|
11
|
+
renderer (`l`, 120 columns by default) and the builders on top of it: `p` (pipes), `e` (expressions), `h` (clause
|
|
12
|
+
helpers), `q` (queries), `t` (table expressions) and the `sql` tag, with `from` exported at the root. Every function
|
|
13
|
+
has one name and one home; the namespaces export factories, not the concrete classes behind them. A pipe is applied
|
|
14
|
+
one way: `pipe(query, p.where(...), ...)`. Pipe syntax is GoogleSQL's, so the result is a `GoogleSqlQuery`; applying
|
|
15
|
+
pipes to one extends its own pipe list instead of nesting (reusable steps are a `Pipe[]` to spread into the call).
|
|
16
|
+
Deliberate nesting is `q.googleQuery({ queryExpression, pipes })`. Pipes that take a list take an array. String
|
|
17
|
+
literals (`e.literalString`, the typed date and time literals) and quoted identifiers (`e.ident`, `t.path(id, true)`)
|
|
18
|
+
are escaped with GoogleSQL's backslash escapes.
|
|
19
|
+
|
|
20
|
+
It also owns datasets: `define` declares one (a table, or a query whose `build` receives a `BuildContext`) and
|
|
21
|
+
records its call site, `problemsOf` judges a definition, and `datasetBuilder` builds them. Every definition carries
|
|
22
|
+
its own `id`, a path of names from the outermost namespace to the dataset (`['analytics', 'events']`), so a dataset
|
|
23
|
+
keeps its name wherever its declaration lives; messages show it dotted, and no segment may contain a dot. A builder is
|
|
24
|
+
one session that builds each
|
|
25
|
+
(dataset, case) once: it refuses two datasets with one id, resolves parameter values from defaults and cases, keeps
|
|
26
|
+
one value per parameter name across inlined queries, inlines references and refuses cycles, and returns the result graph
|
|
27
|
+
(`BuiltDataset`: the query, its parameters, the results it referenced, the assets it loaded). A failure inside a
|
|
28
|
+
referenced dataset is rethrown at the `ctx.ref` call with the original as its `cause`.
|
|
29
|
+
|
|
30
|
+
It does not own table names, projects, asset files or what a query means. The builder takes them from the caller, with
|
|
31
|
+
no defaults: `tableName` gives a table's fully-qualified name, `asset` loads an asset's SQL text. Policies over the
|
|
32
|
+
built SQL, such as a rule against handwritten table ids or references outside a catalog (the result graph lists
|
|
33
|
+
them), are the caller's too. Quoting is the caller's job as well: `from(string)` and `t.path(id, false)` emit the text as given, so use `t.path(id, true)` or
|
|
34
|
+
`e.ident(name)` for identifiers. `create_table` and the ANSI dialect (`dbox.block.ansi`) are not ported.
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { e, from, h, p, pipe, t } from '@leuduan/block'
|
|
40
|
+
|
|
41
|
+
const duplicates = pipe(
|
|
42
|
+
from(t.path('my-project.analytics.events', true)),
|
|
43
|
+
p.select(['id', 'updated_at', 'value']),
|
|
44
|
+
p.extend([h.expression(e.call('count', ['distinct value']), 'distinct_values')]),
|
|
45
|
+
p.where('distinct_values > 1'),
|
|
46
|
+
p.orderBy(['id']),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
duplicates.format() // FROM `my-project.analytics.events` |> SELECT ...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Datasets, built standalone:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { Dataset, datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
|
|
56
|
+
|
|
57
|
+
const EVENTS = define({ kind: 'table', id: ['analytics', 'events'], description: 'Raw events.' })
|
|
58
|
+
const recent = define({
|
|
59
|
+
kind: 'query',
|
|
60
|
+
id: ['analytics', 'recent_events'],
|
|
61
|
+
description: 'Events since a day.',
|
|
62
|
+
params: { day: { type: 'DATE', default: '2026-01-01' } },
|
|
63
|
+
build: (ctx) => pipe(ctx.ref(EVENTS), p.where(sql`day >= ${ctx.param('day')}`)),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const builder = datasetBuilder({
|
|
67
|
+
tableName: (dataset) => `my-project.${Dataset.definitionOf(dataset).id.join('.')}`,
|
|
68
|
+
asset: (file) => {
|
|
69
|
+
throw new Error(`no assets here: ${file}`)
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
const { query, parameters } = builder.build(recent, null) // parameters: [{ name: 'day', type: 'DATE', ... }]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
Run from the repository root:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
pnpm -F @leuduan/block build
|
|
81
|
+
pnpm -F @leuduan/block typecheck
|
|
82
|
+
pnpm -F @leuduan/block test
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`prepack` cleans and builds `dist/`. Publication is a separate operation.
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Dataset } from './descriptor.js';
|
|
2
|
+
import type { ParamType, ParamValue } from './descriptor.js';
|
|
3
|
+
import { QueryExpression } from './sql.js';
|
|
4
|
+
/** A named query parameter as built: the SQL refers to `@name`. */
|
|
5
|
+
export interface Parameter {
|
|
6
|
+
name: string;
|
|
7
|
+
type: ParamType;
|
|
8
|
+
value: ParamValue;
|
|
9
|
+
}
|
|
10
|
+
export interface BuildOptions {
|
|
11
|
+
/** The fully-qualified name of a table dataset. */
|
|
12
|
+
tableName(dataset: Dataset): string;
|
|
13
|
+
/** The SQL text of the asset `file` that `dataset`'s build asks for. */
|
|
14
|
+
asset(file: string, dataset: Dataset): string;
|
|
15
|
+
}
|
|
16
|
+
export interface BuiltDataset {
|
|
17
|
+
readonly dataset: Dataset;
|
|
18
|
+
/** `null` for parameter defaults, and always for a table. */
|
|
19
|
+
readonly case: string | null;
|
|
20
|
+
readonly query: QueryExpression;
|
|
21
|
+
/** The parameters the query uses, its references' included, sorted by name. */
|
|
22
|
+
readonly parameters: readonly Parameter[];
|
|
23
|
+
/** A table's name as `tableName` gave it; `null` for a query. */
|
|
24
|
+
readonly tableName: string | null;
|
|
25
|
+
/** What the build referenced directly, each result once, in the order of first reference. */
|
|
26
|
+
readonly refs: readonly BuiltDataset[];
|
|
27
|
+
/** The asset files the build loaded, as it named them. */
|
|
28
|
+
readonly assets: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
export interface DatasetBuilder {
|
|
31
|
+
/**
|
|
32
|
+
* `dataset` built for `caseName` (`null` builds with the parameter defaults). Throws what stops the build: a
|
|
33
|
+
* blocking problem of a definition, an id another dataset of this session already has, a missing parameter value, a
|
|
34
|
+
* reference cycle, an error of a `build`. A failure
|
|
35
|
+
* inside a referenced dataset is rethrown at the `ctx.ref` call, its error as the `cause`.
|
|
36
|
+
*/
|
|
37
|
+
build(dataset: Dataset, caseName: string | null): BuiltDataset;
|
|
38
|
+
}
|
|
39
|
+
export declare function datasetBuilder(options: BuildOptions): DatasetBuilder;
|
|
40
|
+
//# sourceMappingURL=build.d.ts.map
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Building declared datasets into queries and their named parameters. A builder is one session: it builds each
|
|
2
|
+
// (dataset, case) once, so a dataset referenced from several places is one result. The caller decides what a table
|
|
3
|
+
// is named and where assets come from; everything else about evaluating a definition is here.
|
|
4
|
+
import { Dataset } from './descriptor.js';
|
|
5
|
+
import { lit } from './line.js';
|
|
6
|
+
import { problemsOf } from './problems.js';
|
|
7
|
+
import { raw } from './query.js';
|
|
8
|
+
import { QueryExpression, from } from './sql.js';
|
|
9
|
+
import { path } from './t.js';
|
|
10
|
+
/** The dotted form of a dataset's id: one string per dataset, since no segment contains a dot. */
|
|
11
|
+
function idOf(dataset) {
|
|
12
|
+
return Dataset.definitionOf(dataset).id.join('.');
|
|
13
|
+
}
|
|
14
|
+
function messageOf(error) {
|
|
15
|
+
return error instanceof Error ? error.message : String(error);
|
|
16
|
+
}
|
|
17
|
+
function paramValues(definition, caseName) {
|
|
18
|
+
const values = new Map();
|
|
19
|
+
const bindings = caseName === null ? {} : (definition.cases?.[caseName] ?? {});
|
|
20
|
+
for (const [name, spec] of Object.entries(definition.params ?? {})) {
|
|
21
|
+
const value = (Object.hasOwn(bindings, name) ? bindings[name] : undefined) ?? spec.default;
|
|
22
|
+
if (value !== undefined)
|
|
23
|
+
values.set(name, { name, type: spec.type, value });
|
|
24
|
+
}
|
|
25
|
+
return values;
|
|
26
|
+
}
|
|
27
|
+
/** Inlined queries share one parameter namespace, so a name must mean one value wherever it is used. */
|
|
28
|
+
function mergeParameters(into, parameters, origin) {
|
|
29
|
+
for (const parameter of parameters) {
|
|
30
|
+
const existing = into.get(parameter.name);
|
|
31
|
+
if (existing && (existing.type !== parameter.type || existing.value !== parameter.value)) {
|
|
32
|
+
throw new Error(`parameter @${parameter.name} gets different values here and in ${origin}`);
|
|
33
|
+
}
|
|
34
|
+
into.set(parameter.name, parameter);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function datasetBuilder(options) {
|
|
38
|
+
const results = new Map();
|
|
39
|
+
const datasetsById = new Map();
|
|
40
|
+
const active = [];
|
|
41
|
+
function cached(dataset, caseName) {
|
|
42
|
+
return results.get(dataset)?.get(Dataset.definitionOf(dataset).kind === 'table' ? null : caseName);
|
|
43
|
+
}
|
|
44
|
+
/** `loop` runs from a reference's target to a dataset still being built. */
|
|
45
|
+
function cycleError(loop) {
|
|
46
|
+
const start = active.indexOf(loop[loop.length - 1]);
|
|
47
|
+
return new Error(`reference cycle: ${[...active.slice(start), ...loop].map(idOf).join(' -> ')}`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The path from a result built earlier down to a dataset being built now, if there is one. A build that caught a
|
|
51
|
+
* failed reference can have succeeded with a loop in its references, so reusing its result must check them.
|
|
52
|
+
*/
|
|
53
|
+
function pathToActive(result, visited) {
|
|
54
|
+
if (active.includes(result.dataset))
|
|
55
|
+
return [result.dataset];
|
|
56
|
+
visited.add(result);
|
|
57
|
+
for (const ref of result.refs) {
|
|
58
|
+
if (visited.has(ref))
|
|
59
|
+
continue;
|
|
60
|
+
const path = pathToActive(ref, visited);
|
|
61
|
+
if (path)
|
|
62
|
+
return [result.dataset, ...path];
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function buildQuery(dataset, definition, caseName) {
|
|
67
|
+
if (caseName !== null && !Object.hasOwn(definition.cases ?? {}, caseName)) {
|
|
68
|
+
throw new Error(`no case "${caseName}"`);
|
|
69
|
+
}
|
|
70
|
+
const values = paramValues(definition, caseName);
|
|
71
|
+
const missing = Object.keys(definition.params ?? {}).filter((name) => !values.has(name));
|
|
72
|
+
if (missing.length > 0)
|
|
73
|
+
throw new Error(`parameters without a value (pass { case }): ${missing.join(', ')}`);
|
|
74
|
+
const parameters = new Map();
|
|
75
|
+
const refs = new Set();
|
|
76
|
+
const assets = new Set();
|
|
77
|
+
const query = definition.build({
|
|
78
|
+
ref(target, refOptions) {
|
|
79
|
+
if (!(target instanceof Dataset))
|
|
80
|
+
throw new Error('ctx.ref needs a dataset');
|
|
81
|
+
const id = idOf(target);
|
|
82
|
+
if (active.includes(target))
|
|
83
|
+
throw cycleError([target]);
|
|
84
|
+
const targetCase = refOptions?.case ?? null;
|
|
85
|
+
let child = cached(target, targetCase);
|
|
86
|
+
if (child) {
|
|
87
|
+
const loop = pathToActive(child, new Set());
|
|
88
|
+
if (loop)
|
|
89
|
+
throw cycleError(loop);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
try {
|
|
93
|
+
child = build(target, targetCase);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new Error(`${id} does not render: ${messageOf(error)}`, { cause: error });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
mergeParameters(parameters, child.parameters, id);
|
|
100
|
+
refs.add(child);
|
|
101
|
+
return child.query;
|
|
102
|
+
},
|
|
103
|
+
param(name) {
|
|
104
|
+
const value = values.get(name);
|
|
105
|
+
if (!value)
|
|
106
|
+
throw new Error(`unknown parameter "${name}"`);
|
|
107
|
+
mergeParameters(parameters, [value], 'a dataset it references');
|
|
108
|
+
return lit(`@${name}`);
|
|
109
|
+
},
|
|
110
|
+
asset(file) {
|
|
111
|
+
const text = options.asset(file, dataset);
|
|
112
|
+
assets.add(file);
|
|
113
|
+
return raw(text.replace(/[\s;]+$/, ''));
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
if (!(query instanceof QueryExpression))
|
|
117
|
+
throw new Error('build must return a block query');
|
|
118
|
+
return {
|
|
119
|
+
dataset,
|
|
120
|
+
case: caseName,
|
|
121
|
+
query,
|
|
122
|
+
parameters: [...parameters.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
|
123
|
+
tableName: null,
|
|
124
|
+
refs: [...refs],
|
|
125
|
+
assets: [...assets],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function build(dataset, caseName) {
|
|
129
|
+
const definition = Dataset.definitionOf(dataset);
|
|
130
|
+
const key = definition.kind === 'table' ? null : caseName;
|
|
131
|
+
const memo = cached(dataset, key);
|
|
132
|
+
if (memo)
|
|
133
|
+
return memo;
|
|
134
|
+
const blocking = problemsOf(definition).find((problem) => problem.blocks);
|
|
135
|
+
if (blocking)
|
|
136
|
+
throw new Error(blocking.message);
|
|
137
|
+
const id = idOf(dataset);
|
|
138
|
+
const sameId = datasetsById.get(id);
|
|
139
|
+
if (sameId && sameId !== dataset)
|
|
140
|
+
throw new Error(`two datasets have the id "${id}"`);
|
|
141
|
+
datasetsById.set(id, dataset);
|
|
142
|
+
let result;
|
|
143
|
+
active.push(dataset);
|
|
144
|
+
try {
|
|
145
|
+
if (definition.kind === 'table') {
|
|
146
|
+
const tableName = options.tableName(dataset);
|
|
147
|
+
result = {
|
|
148
|
+
dataset,
|
|
149
|
+
case: null,
|
|
150
|
+
query: from(path(tableName, true)),
|
|
151
|
+
parameters: [],
|
|
152
|
+
tableName,
|
|
153
|
+
refs: [],
|
|
154
|
+
assets: [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
result = buildQuery(dataset, definition, key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
active.pop();
|
|
163
|
+
}
|
|
164
|
+
let byCase = results.get(dataset);
|
|
165
|
+
if (!byCase)
|
|
166
|
+
results.set(dataset, (byCase = new Map()));
|
|
167
|
+
byCase.set(key, result);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
return { build };
|
|
171
|
+
}
|
package/dist/define.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Dataset } from './descriptor.js';
|
|
2
|
+
import type { ParamsSpec, QueryDefinition, TableDefinition } from './descriptor.js';
|
|
3
|
+
/**
|
|
4
|
+
* Declare a dataset: a table (`kind: 'table'`) or a derived query (`kind: 'query'`). The result is opaque, records
|
|
5
|
+
* the call site and nothing global.
|
|
6
|
+
*/
|
|
7
|
+
export declare function define<const P extends ParamsSpec = ParamsSpec>(definition: TableDefinition | QueryDefinition<P>): Dataset;
|
|
8
|
+
//# sourceMappingURL=define.d.ts.map
|
package/dist/define.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Dataset } from './descriptor.js';
|
|
2
|
+
const PAREN_FRAME = /\((.+):(\d+):\d+\)$/;
|
|
3
|
+
const BARE_FRAME = /^\s*at (.+):(\d+):\d+$/;
|
|
4
|
+
/** Frames of the code that called `define`: V8's stack minus its header line, `callerFrames` and `define`. */
|
|
5
|
+
function callerFrames() {
|
|
6
|
+
const options = Error;
|
|
7
|
+
const limit = options.stackTraceLimit;
|
|
8
|
+
options.stackTraceLimit = 40;
|
|
9
|
+
const stack = new Error().stack ?? '';
|
|
10
|
+
options.stackTraceLimit = limit;
|
|
11
|
+
const frames = [];
|
|
12
|
+
for (const line of stack.split('\n').slice(3)) {
|
|
13
|
+
const match = PAREN_FRAME.exec(line) ?? BARE_FRAME.exec(line);
|
|
14
|
+
if (match)
|
|
15
|
+
frames.push({ file: match[1], line: Number(match[2]) });
|
|
16
|
+
}
|
|
17
|
+
return frames;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Declare a dataset: a table (`kind: 'table'`) or a derived query (`kind: 'query'`). The result is opaque, records
|
|
21
|
+
* the call site and nothing global.
|
|
22
|
+
*/
|
|
23
|
+
export function define(definition) {
|
|
24
|
+
return new Dataset(definition, callerFrames());
|
|
25
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Line } from './line.js';
|
|
2
|
+
import type { QueryExpression } from './sql.js';
|
|
3
|
+
export declare const PARAM_TYPES: readonly ['STRING', 'INT64', 'FLOAT64', 'BOOL', 'DATE', 'DATETIME', 'TIMESTAMP', 'TIME'];
|
|
4
|
+
export type ParamType = (typeof PARAM_TYPES)[number];
|
|
5
|
+
export type ParamValue = string | number | boolean;
|
|
6
|
+
export interface ParamSpec {
|
|
7
|
+
type: ParamType;
|
|
8
|
+
default?: ParamValue;
|
|
9
|
+
}
|
|
10
|
+
export type ParamsSpec = Record<string, ParamSpec>;
|
|
11
|
+
interface CommonDefinition {
|
|
12
|
+
/**
|
|
13
|
+
* Names the dataset wherever it is declared, so moving the declaration does not rename it. Segments run from the
|
|
14
|
+
* outermost namespace to the name; none contains a dot, so the dotted form in messages names one dataset.
|
|
15
|
+
*/
|
|
16
|
+
id: readonly [string, ...string[]];
|
|
17
|
+
description: string;
|
|
18
|
+
/** Notes on what columns mean. Free text, never a schema. */
|
|
19
|
+
columns?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
export interface TableDefinition extends CommonDefinition {
|
|
22
|
+
kind: 'table';
|
|
23
|
+
/** Physical table name, for the builder's `tableName` to use. */
|
|
24
|
+
table?: string;
|
|
25
|
+
partitionedBy?: readonly string[];
|
|
26
|
+
clusteredBy?: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
export interface BuildContext<Name extends string = string> {
|
|
29
|
+
/**
|
|
30
|
+
* A block query over another dataset. A table is `FROM` its name (the builder's `tableName`); a query is the
|
|
31
|
+
* referenced query itself, inlined, built with its parameter defaults or with `case`.
|
|
32
|
+
*/
|
|
33
|
+
ref(dataset: Dataset, options?: {
|
|
34
|
+
case?: string;
|
|
35
|
+
}): QueryExpression;
|
|
36
|
+
/** The named query parameter `@name`; the build result carries its type and value. */
|
|
37
|
+
param(name: Name): Line;
|
|
38
|
+
/** The raw SQL of the asset `file` (the builder's `asset` loads it), minus a trailing semicolon. */
|
|
39
|
+
asset(file: string): QueryExpression;
|
|
40
|
+
}
|
|
41
|
+
export interface QueryDefinition<P extends ParamsSpec = ParamsSpec> extends CommonDefinition {
|
|
42
|
+
kind: 'query';
|
|
43
|
+
/** `'metric'` marks the canonical definition of a business measure. */
|
|
44
|
+
role?: 'metric';
|
|
45
|
+
/** Every parameter has a default, or every case binds it. */
|
|
46
|
+
params?: P;
|
|
47
|
+
/** Named parameter sets; each renders on its own. */
|
|
48
|
+
cases?: Record<string, {
|
|
49
|
+
[Name in keyof P]?: ParamValue;
|
|
50
|
+
}>;
|
|
51
|
+
build: (ctx: BuildContext<keyof P & string>) => QueryExpression;
|
|
52
|
+
}
|
|
53
|
+
export type Definition = TableDefinition | QueryDefinition;
|
|
54
|
+
export interface Frame {
|
|
55
|
+
file: string;
|
|
56
|
+
line: number;
|
|
57
|
+
}
|
|
58
|
+
export declare class Dataset {
|
|
59
|
+
#private;
|
|
60
|
+
constructor(definition: Definition, frames: readonly Frame[]);
|
|
61
|
+
static definitionOf(dataset: Dataset): Definition;
|
|
62
|
+
/** The stack frames of the `define(...)` call, innermost first, `define`'s own frames removed. */
|
|
63
|
+
static framesOf(dataset: Dataset): readonly Frame[];
|
|
64
|
+
}
|
|
65
|
+
export {};
|
|
66
|
+
//# sourceMappingURL=descriptor.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The dataset descriptor `define(...)` returns: a table or a derived query, declared with notes and a `build` over
|
|
2
|
+
// the query model. `datasetBuilder` builds it; `Dataset.definitionOf` and `Dataset.framesOf` are for callers that
|
|
3
|
+
// check or locate declarations.
|
|
4
|
+
export const PARAM_TYPES = ['STRING', 'INT64', 'FLOAT64', 'BOOL', 'DATE', 'DATETIME', 'TIMESTAMP', 'TIME'];
|
|
5
|
+
export class Dataset {
|
|
6
|
+
#definition;
|
|
7
|
+
#frames;
|
|
8
|
+
constructor(definition, frames) {
|
|
9
|
+
this.#definition = definition;
|
|
10
|
+
this.#frames = frames;
|
|
11
|
+
}
|
|
12
|
+
static definitionOf(dataset) {
|
|
13
|
+
return dataset.#definition;
|
|
14
|
+
}
|
|
15
|
+
/** The stack frames of the `define(...)` call, innermost first, `define`'s own frames removed. */
|
|
16
|
+
static framesOf(dataset) {
|
|
17
|
+
return dataset.#frames;
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/expr.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Line, type LineOrStr } from './line.js';
|
|
2
|
+
import { type QueryExpression } from './sql.js';
|
|
3
|
+
export declare function compare(left: LineOrStr, right: LineOrStr, op?: string): Line;
|
|
4
|
+
export declare function coalesce(args: readonly LineOrStr[]): Line;
|
|
5
|
+
export declare function like(expr: LineOrStr, pattern: string, notLike?: boolean): Line;
|
|
6
|
+
export declare function in_(e: LineOrStr, values: readonly (string | number | Line)[]): Line;
|
|
7
|
+
export declare function cast(expr: LineOrStr, toType: string): Line;
|
|
8
|
+
export declare function literalBool(value: boolean): Line;
|
|
9
|
+
export declare function literalNumber(value: number): Line;
|
|
10
|
+
export declare function literalString(value: string): Line;
|
|
11
|
+
export declare function isNull(expr: LineOrStr, notNull?: boolean): Line;
|
|
12
|
+
export declare function concat(args: readonly LineOrStr[]): Line;
|
|
13
|
+
export declare function literalDate(value: Date | string): Line;
|
|
14
|
+
export declare function literalDatetime(value: Date | string): Line;
|
|
15
|
+
export declare function literalTime(value: Date | string): Line;
|
|
16
|
+
export declare function date(expr: LineOrStr): Line;
|
|
17
|
+
export declare function literalTimestamp(value: Date | string, timezone?: string): Line;
|
|
18
|
+
export declare function timestamp(expr: LineOrStr): Line;
|
|
19
|
+
export declare function call(func: LineOrStr, args: readonly LineOrStr[]): Line;
|
|
20
|
+
export declare function if_(condition: LineOrStr, trueExpr: LineOrStr, falseExpr: LineOrStr): Line;
|
|
21
|
+
export declare function struct(fields: readonly LineOrStr[]): Line;
|
|
22
|
+
export declare function between(expr: LineOrStr, low: LineOrStr, high: LineOrStr, notBetween?: boolean): Line;
|
|
23
|
+
export declare function not(expr: LineOrStr): Line;
|
|
24
|
+
export declare function case_(opts: {
|
|
25
|
+
expr?: LineOrStr;
|
|
26
|
+
whens: readonly [LineOrStr, LineOrStr][];
|
|
27
|
+
elseExpr?: LineOrStr;
|
|
28
|
+
}): Line;
|
|
29
|
+
export declare function inSubquery(expr: LineOrStr, query: QueryExpression, notIn?: boolean): Line;
|
|
30
|
+
export declare function or(exprs: readonly LineOrStr[]): Line;
|
|
31
|
+
export declare function and(exprs: readonly LineOrStr[]): Line;
|
|
32
|
+
export declare function star(excepts?: readonly LineOrStr[]): Line;
|
|
33
|
+
/** Backtick-quoted GoogleSQL identifier; escaped like a string literal, so the name is exactly `name`. */
|
|
34
|
+
export declare function ident(name: string): Line;
|
|
35
|
+
export declare const TS_MIN: Line;
|
|
36
|
+
export declare const TS_MAX: Line;
|
|
37
|
+
//# sourceMappingURL=expr.d.ts.map
|
package/dist/expr.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Port of py/dbox/dbox/block/expr.py
|
|
2
|
+
// Scalar SQL expression helpers — all return Line.
|
|
3
|
+
import * as l from './line.js';
|
|
4
|
+
import {} from './line.js';
|
|
5
|
+
import {} from './sql.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Internal helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
10
|
+
/**
|
|
11
|
+
* The one place a string becomes a quoted GoogleSQL token, a string literal or (with a backtick) a quoted
|
|
12
|
+
* identifier, which take the same escapes. GoogleSQL escapes with backslashes (a doubled quote is not an escape), so
|
|
13
|
+
* `\`, the delimiter and every control character are escaped and the token has exactly the value of `value`. A
|
|
14
|
+
* string with a lone surrogate has no UTF-8 form and is refused.
|
|
15
|
+
*/
|
|
16
|
+
function quoteString(value, quote = "'") {
|
|
17
|
+
if (LONE_SURROGATE.test(value))
|
|
18
|
+
throw new Error('A string with a lone surrogate cannot be a GoogleSQL literal');
|
|
19
|
+
let body = '';
|
|
20
|
+
for (const ch of value) {
|
|
21
|
+
if (ch === '\\' || ch === quote)
|
|
22
|
+
body += '\\' + ch;
|
|
23
|
+
else if (ch === '\n')
|
|
24
|
+
body += '\\n';
|
|
25
|
+
else if (ch === '\r')
|
|
26
|
+
body += '\\r';
|
|
27
|
+
else if (ch === '\t')
|
|
28
|
+
body += '\\t';
|
|
29
|
+
else if (ch < ' ' || ch === '\x7f')
|
|
30
|
+
body += '\\x' + ch.charCodeAt(0).toString(16).padStart(2, '0');
|
|
31
|
+
else
|
|
32
|
+
body += ch;
|
|
33
|
+
}
|
|
34
|
+
return quote + body + quote;
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Public expression helpers
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
export function compare(left, right, op = '=') {
|
|
40
|
+
return l.ladder([l.ensure(left), l.lit(op), l.ensure(right)]);
|
|
41
|
+
}
|
|
42
|
+
export function coalesce(args) {
|
|
43
|
+
return call(l.keyword('COALESCE'), args);
|
|
44
|
+
}
|
|
45
|
+
export function like(expr, pattern, notLike = false) {
|
|
46
|
+
return l.threeway([l.ensure(expr), l.keyword(notLike ? 'NOT LIKE' : 'LIKE'), literalString(pattern)]);
|
|
47
|
+
}
|
|
48
|
+
export function in_(e, values) {
|
|
49
|
+
const allStr = values.length > 0 && values.every((v) => typeof v === 'string');
|
|
50
|
+
const allNum = values.length > 0 && values.every((v) => typeof v === 'number');
|
|
51
|
+
const vLines = allStr
|
|
52
|
+
? values.map((v) => literalString(v))
|
|
53
|
+
: allNum
|
|
54
|
+
? values.map((v) => literalNumber(v))
|
|
55
|
+
: values.map((v) => l.ensure(v));
|
|
56
|
+
return l.ladder([l.ensure(e), l.keyword('IN'), l.paren(l.stackComma(vLines))]);
|
|
57
|
+
}
|
|
58
|
+
export function cast(expr, toType) {
|
|
59
|
+
return l.callLike({ call: l.keyword('CAST'), line: l.stack([l.ensure(expr), l.keyword(`AS ${toType}`)]) });
|
|
60
|
+
}
|
|
61
|
+
export function literalBool(value) {
|
|
62
|
+
return l.lit(value ? 'TRUE' : 'FALSE');
|
|
63
|
+
}
|
|
64
|
+
export function literalNumber(value) {
|
|
65
|
+
return l.lit(String(value));
|
|
66
|
+
}
|
|
67
|
+
export function literalString(value) {
|
|
68
|
+
return l.lit(quoteString(value));
|
|
69
|
+
}
|
|
70
|
+
export function isNull(expr, notNull = false) {
|
|
71
|
+
return l.indentNext([l.ensure(expr), l.keyword(notNull ? 'IS NOT NULL' : 'IS NULL')]);
|
|
72
|
+
}
|
|
73
|
+
export function concat(args) {
|
|
74
|
+
return call(l.keyword('CONCAT'), args);
|
|
75
|
+
}
|
|
76
|
+
export function literalDate(value) {
|
|
77
|
+
const s = typeof value === 'string' ? value : value.toISOString().slice(0, 10);
|
|
78
|
+
return l.lit(`DATE ${quoteString(s)}`);
|
|
79
|
+
}
|
|
80
|
+
export function literalDatetime(value) {
|
|
81
|
+
const s = typeof value === 'string' ? value : value.toISOString().replace('T', ' ').replace('Z', '');
|
|
82
|
+
return l.lit(`DATETIME ${quoteString(s)}`);
|
|
83
|
+
}
|
|
84
|
+
export function literalTime(value) {
|
|
85
|
+
const s = typeof value === 'string' ? value : value.toISOString().slice(11, 19);
|
|
86
|
+
return l.lit(`TIME ${quoteString(s)}`);
|
|
87
|
+
}
|
|
88
|
+
export function date(expr) {
|
|
89
|
+
return call(l.keyword('DATE'), [expr]);
|
|
90
|
+
}
|
|
91
|
+
export function literalTimestamp(value, timezone) {
|
|
92
|
+
const s = typeof value === 'string' ? value : value.toISOString().replace('T', ' ').replace('Z', '');
|
|
93
|
+
if (timezone !== undefined) {
|
|
94
|
+
return call('timestamp', [l.lit(quoteString(s)), l.lit(quoteString(timezone))]);
|
|
95
|
+
}
|
|
96
|
+
return l.combine([l.keyword('TIMESTAMP'), l.lit(quoteString(s))]);
|
|
97
|
+
}
|
|
98
|
+
export function timestamp(expr) {
|
|
99
|
+
return call(l.keyword('TIMESTAMP'), [expr]);
|
|
100
|
+
}
|
|
101
|
+
export function call(func, args) {
|
|
102
|
+
return l.callLike({ call: l.ensure(func), line: l.stackComma(args.map((a) => l.ensure(a))) });
|
|
103
|
+
}
|
|
104
|
+
export function if_(condition, trueExpr, falseExpr) {
|
|
105
|
+
return call(l.keyword('IF'), [condition, trueExpr, falseExpr]);
|
|
106
|
+
}
|
|
107
|
+
export function struct(fields) {
|
|
108
|
+
return call(l.keyword('STRUCT'), fields);
|
|
109
|
+
}
|
|
110
|
+
export function between(expr, low, high, notBetween = false) {
|
|
111
|
+
return l.indentNext([
|
|
112
|
+
l.ensure(expr),
|
|
113
|
+
l.indentNext([
|
|
114
|
+
l.keyword(notBetween ? 'NOT BETWEEN' : 'BETWEEN'),
|
|
115
|
+
l.stack([l.ensure(low), l.keyword('AND'), l.ensure(high)]),
|
|
116
|
+
]),
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
export function not(expr) {
|
|
120
|
+
return l.indentNext([l.keyword('NOT'), l.ensure(expr)]);
|
|
121
|
+
}
|
|
122
|
+
export function case_(opts) {
|
|
123
|
+
const whenLines = opts.whens.map(([matchExpr, result]) => l.stack([
|
|
124
|
+
l.indentNext([l.keyword('when'), l.ensure(matchExpr)]),
|
|
125
|
+
l.indentNext([l.keyword('then'), l.ensure(result)]),
|
|
126
|
+
]));
|
|
127
|
+
return l.indentMid([
|
|
128
|
+
l.indentNext([l.keyword('case'), opts.expr !== undefined ? l.ensure(opts.expr) : null]),
|
|
129
|
+
l.stack(whenLines),
|
|
130
|
+
opts.elseExpr !== undefined ? l.indentNext([l.keyword('else'), l.ensure(opts.elseExpr)]) : null,
|
|
131
|
+
l.keyword('end'),
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
export function inSubquery(expr, query, notIn = false) {
|
|
135
|
+
return l.indentNext([
|
|
136
|
+
l.stack([l.ensure(expr), l.keyword(notIn ? 'NOT IN' : 'IN')]),
|
|
137
|
+
query.toTableExpression().toLine(),
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
export function or(exprs) {
|
|
141
|
+
return l.stackBinOp(exprs.map((e) => l.ensure(e)), l.keyword('OR'));
|
|
142
|
+
}
|
|
143
|
+
export function and(exprs) {
|
|
144
|
+
return l.stackBinOp(exprs.map((e) => l.ensure(e)), l.keyword('AND'));
|
|
145
|
+
}
|
|
146
|
+
export function star(excepts = []) {
|
|
147
|
+
if (excepts.length === 0)
|
|
148
|
+
return l.lit('*');
|
|
149
|
+
return l.callLike({ call: l.lit('* EXCEPT'), line: l.stackComma(excepts.map((e) => l.ensure(e))) });
|
|
150
|
+
}
|
|
151
|
+
/** Backtick-quoted GoogleSQL identifier; escaped like a string literal, so the name is exactly `name`. */
|
|
152
|
+
export function ident(name) {
|
|
153
|
+
return l.lit(quoteString(name, '`'));
|
|
154
|
+
}
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Timestamp extremes
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
export const TS_MIN = l.keyword('TIMESTAMP "1900-01-01 00:00:00.000000 UTC"');
|
|
159
|
+
export const TS_MAX = l.keyword('TIMESTAMP "9999-12-31 23:59:59.999999 UTC"');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Line, type LineOrStr } from './line.js';
|
|
2
|
+
import { type QueryExpression, type MapOrRecord } from './sql.js';
|
|
3
|
+
/** Build WITH / |> WITH clause from a ctes mapping (insertion-order preserved). */
|
|
4
|
+
export declare function ctes(ctesMap: MapOrRecord<QueryExpression>, opts?: {
|
|
5
|
+
withPipe?: boolean;
|
|
6
|
+
}): Line;
|
|
7
|
+
export declare function array(elements: readonly LineOrStr[]): Line;
|
|
8
|
+
/**
|
|
9
|
+
* Wrap an expression with an optional alias.
|
|
10
|
+
* Uses combine([keyword("AS"), lit(alias)]) — matching helpers.py:29.
|
|
11
|
+
*/
|
|
12
|
+
export declare function expression(expr: LineOrStr, alias?: string): Line;
|
|
13
|
+
export declare function analytic(expr: LineOrStr, window?: LineOrStr): Line;
|
|
14
|
+
export declare function window(partitionBy: readonly LineOrStr[], orderBy?: readonly LineOrStr[]): Line;
|
|
15
|
+
export declare function set(name: string, value: LineOrStr): Line;
|
|
16
|
+
export declare function desc(expr: LineOrStr): Line;
|
|
17
|
+
export declare function asc(expr: LineOrStr): Line;
|
|
18
|
+
/** Build "name AS (query)" — used for CTE-like subquery aliases. */
|
|
19
|
+
export declare function aliasSubquery(name: string, query: LineOrStr): Line;
|
|
20
|
+
/** Build "SELECT AS STRUCT col1, col2, ..." line fragment. */
|
|
21
|
+
export declare function selectAsStruct(columns: readonly string[]): Line;
|
|
22
|
+
/** UNNEST expression with optional alias. */
|
|
23
|
+
export declare function unnest(source: LineOrStr, alias?: string): Line;
|
|
24
|
+
/** Build STRUCT<field1, field2, ...> type expression. */
|
|
25
|
+
export declare function structType(fields: readonly LineOrStr[] | MapOrRecord<LineOrStr>): Line;
|
|
26
|
+
/** Build "WINDOW name AS (window_def), ..." clause. */
|
|
27
|
+
export declare function windows(windowsMap: MapOrRecord<LineOrStr>): Line;
|
|
28
|
+
//# sourceMappingURL=helpers.d.ts.map
|