@leuduan/block 2.0.0 → 3.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/README.md CHANGED
@@ -12,22 +12,28 @@ renderer (`l`, 120 columns by default) and the builders on top of it: `p` (pipes
12
12
  helpers), `q` (queries), `t` (table expressions) and the `sql` tag, with `from` exported at the root. Every function
13
13
  has one name and one home; the namespaces export factories, not the concrete classes behind them. A pipe is applied
14
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).
15
+ pipes to one extends its own pipe list instead of nesting. A step can also be a function from the query built so far
16
+ to the next query, for what `|>` cannot express (the input on the right of a join, inside a subquery, or rewritten);
17
+ later steps apply to what it returns. Reusable steps are a `Pipe[]` to spread into the call, or such a function, with
18
+ any inputs taken as arguments of the function that makes them.
16
19
  Deliberate nesting is `q.googleQuery({ queryExpression, pipes })`. Pipes that take a list take an array. String
17
20
  literals (`e.literalString`, the typed date and time literals) and quoted identifiers (`e.ident`, `t.path(id, true)`)
18
21
  are escaped with GoogleSQL's backslash escapes.
19
22
 
20
23
  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. A builder is one session
22
- that builds each (dataset, case) once: it resolves parameter values from defaults and cases, keeps one value per
23
- parameter name across inlined queries, inlines references and refuses cycles, and returns the result graph
24
+ records its call site, `problemsOf` judges a definition, and `datasetBuilder` builds them. Every definition carries
25
+ its own `id`, a path of names from the outermost namespace to the dataset (`['analytics', 'events']`), so a dataset
26
+ keeps its name wherever its declaration lives; messages show it dotted, and no segment may contain a dot. A builder is
27
+ one session that builds each
28
+ (dataset, case) once: it refuses two datasets with one id, resolves parameter values from defaults and cases, keeps
29
+ one value per parameter name across inlined queries, inlines references and refuses cycles, and returns the result graph
24
30
  (`BuiltDataset`: the query, its parameters, the results it referenced, the assets it loaded). A failure inside a
25
31
  referenced dataset is rethrown at the `ctx.ref` call with the original as its `cause`.
26
32
 
27
33
  It does not own table names, projects, asset files or what a query means. The builder takes them from the caller, with
28
- no defaults: `idOf` names a dataset in messages (and refuses one the caller does not know), `tableName` gives a table's
29
- fully-qualified name, `asset` loads an asset's SQL text. Policies over the built SQL, such as a rule
30
- against handwritten table ids, 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
+ no defaults: `tableName` gives a table's fully-qualified name, `asset` loads an asset's SQL text. Policies over the
35
+ built SQL, such as a rule against handwritten table ids or references outside a catalog (the result graph lists
36
+ 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
31
37
  `e.ident(name)` for identifiers. `create_table` and the ANSI dialect (`dbox.block.ansi`) are not ported.
32
38
 
33
39
  ## Use
@@ -49,28 +55,19 @@ duplicates.format() // FROM `my-project.analytics.events` |> SELECT ...
49
55
  Datasets, built standalone:
50
56
 
51
57
  ```ts
52
- import { datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
58
+ import { Dataset, datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
53
59
 
54
- const EVENTS = define({ kind: 'table', description: 'Raw events.', grain: 'one row per event' })
60
+ const EVENTS = define({ kind: 'table', id: ['analytics', 'events'], description: 'Raw events.' })
55
61
  const recent = define({
56
62
  kind: 'query',
63
+ id: ['analytics', 'recent_events'],
57
64
  description: 'Events since a day.',
58
- grain: 'one row per event',
59
65
  params: { day: { type: 'DATE', default: '2026-01-01' } },
60
66
  build: (ctx) => pipe(ctx.ref(EVENTS), p.where(sql`day >= ${ctx.param('day')}`)),
61
67
  })
62
68
 
63
- const names = new Map([
64
- [EVENTS, 'events'],
65
- [recent, 'recent'],
66
- ])
67
69
  const builder = datasetBuilder({
68
- idOf: (dataset) => {
69
- const name = names.get(dataset)
70
- if (name === undefined) throw new Error('not a dataset of this catalog')
71
- return name
72
- },
73
- tableName: () => 'my-project.analytics.events',
70
+ tableName: (dataset) => `my-project.${Dataset.definitionOf(dataset).id.join('.')}`,
74
71
  asset: (file) => {
75
72
  throw new Error(`no assets here: ${file}`)
76
73
  },
package/dist/build.d.ts CHANGED
@@ -8,11 +8,6 @@ export interface Parameter {
8
8
  value: ParamValue;
9
9
  }
10
10
  export interface BuildOptions {
11
- /**
12
- * The caller's id for a dataset, used in messages. It throws for a dataset the caller does not know, which refuses
13
- * a `ctx.ref` to it.
14
- */
15
- idOf(dataset: Dataset): string;
16
11
  /** The fully-qualified name of a table dataset. */
17
12
  tableName(dataset: Dataset): string;
18
13
  /** The SQL text of the asset `file` that `dataset`'s build asks for. */
@@ -35,7 +30,8 @@ export interface BuiltDataset {
35
30
  export interface DatasetBuilder {
36
31
  /**
37
32
  * `dataset` built for `caseName` (`null` builds with the parameter defaults). Throws what stops the build: a
38
- * blocking problem of a definition, a missing parameter value, a reference cycle, an error of a `build`. A failure
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
39
35
  * inside a referenced dataset is rethrown at the `ctx.ref` call, its error as the `cause`.
40
36
  */
41
37
  build(dataset: Dataset, caseName: string | null): BuiltDataset;
package/dist/build.js CHANGED
@@ -7,6 +7,10 @@ import { problemsOf } from './problems.js';
7
7
  import { raw } from './query.js';
8
8
  import { QueryExpression, from } from './sql.js';
9
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
+ }
10
14
  function messageOf(error) {
11
15
  return error instanceof Error ? error.message : String(error);
12
16
  }
@@ -32,6 +36,7 @@ function mergeParameters(into, parameters, origin) {
32
36
  }
33
37
  export function datasetBuilder(options) {
34
38
  const results = new Map();
39
+ const datasetsById = new Map();
35
40
  const active = [];
36
41
  function cached(dataset, caseName) {
37
42
  return results.get(dataset)?.get(Dataset.definitionOf(dataset).kind === 'table' ? null : caseName);
@@ -39,7 +44,7 @@ export function datasetBuilder(options) {
39
44
  /** `loop` runs from a reference's target to a dataset still being built. */
40
45
  function cycleError(loop) {
41
46
  const start = active.indexOf(loop[loop.length - 1]);
42
- return new Error(`reference cycle: ${[...active.slice(start), ...loop].map(options.idOf).join(' -> ')}`);
47
+ return new Error(`reference cycle: ${[...active.slice(start), ...loop].map(idOf).join(' -> ')}`);
43
48
  }
44
49
  /**
45
50
  * The path from a result built earlier down to a dataset being built now, if there is one. A build that caught a
@@ -73,7 +78,7 @@ export function datasetBuilder(options) {
73
78
  ref(target, refOptions) {
74
79
  if (!(target instanceof Dataset))
75
80
  throw new Error('ctx.ref needs a dataset');
76
- const id = options.idOf(target);
81
+ const id = idOf(target);
77
82
  if (active.includes(target))
78
83
  throw cycleError([target]);
79
84
  const targetCase = refOptions?.case ?? null;
@@ -129,6 +134,11 @@ export function datasetBuilder(options) {
129
134
  const blocking = problemsOf(definition).find((problem) => problem.blocks);
130
135
  if (blocking)
131
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);
132
142
  let result;
133
143
  active.push(dataset);
134
144
  try {
@@ -9,18 +9,20 @@ export interface ParamSpec {
9
9
  }
10
10
  export type ParamsSpec = Record<string, ParamSpec>;
11
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[]];
12
17
  description: string;
13
- grain: string;
14
18
  /** Notes on what columns mean. Free text, never a schema. */
15
19
  columns?: Record<string, string>;
16
- /** Notes on how this dataset joins others, keyed by the other dataset's name. */
17
- relations?: Record<string, string>;
18
20
  }
19
21
  export interface TableDefinition extends CommonDefinition {
20
22
  kind: 'table';
21
23
  /** Physical table name, for the builder's `tableName` to use. */
22
24
  table?: string;
23
- partitionedBy?: string;
25
+ partitionedBy?: readonly string[];
24
26
  clusteredBy?: readonly string[];
25
27
  }
26
28
  export interface BuildContext<Name extends string = string> {
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export * as q from './query.js';
6
6
  export * as t from './t.js';
7
7
  export type { Line, LineOrStr, Options } from './line.js';
8
8
  export { DEFAULT_OPTIONS } from './line.js';
9
- export type { SqlSegment, Pipe, TableExpression, GoogleSqlQuery } from './sql.js';
9
+ export type { SqlSegment, Pipe, PipeStep, TableExpression, GoogleSqlQuery } from './sql.js';
10
10
  export { QueryExpression, from, pipe, sql } from './sql.js';
11
11
  export { define } from './define.js';
12
12
  export { Dataset, PARAM_TYPES } from './descriptor.js';
package/dist/problems.js CHANGED
@@ -46,12 +46,13 @@ export function problemsOf(def) {
46
46
  }
47
47
  const out = [];
48
48
  const flag = (message, blocks = false) => out.push({ message, blocks });
49
+ const id = def.id;
50
+ if (!(Array.isArray(id) && id.length > 0 && id.every((segment) => nonEmpty(segment) && !segment.includes('.')))) {
51
+ flag('`id` is a non-empty list of names without dots', true);
52
+ }
49
53
  if (!nonEmpty(def.description))
50
54
  flag('`description` is missing or empty');
51
- if (!nonEmpty(def.grain))
52
- flag('`grain` is missing or empty');
53
55
  notes('columns', def.columns, out);
54
- notes('relations', def.relations, out);
55
56
  if (def.kind === 'table') {
56
57
  for (const field of ['build', 'params', 'cases', 'role']) {
57
58
  if (def[field] !== undefined)
@@ -60,11 +61,11 @@ export function problemsOf(def) {
60
61
  if (def.table !== undefined && !(typeof def.table === 'string' && TABLE_NAME.test(def.table))) {
61
62
  flag('`table` is not a valid table name', true);
62
63
  }
63
- if (def.partitionedBy !== undefined && !nonEmpty(def.partitionedBy))
64
- flag('`partitionedBy` is a column name');
65
- const clustered = def.clusteredBy;
66
- if (clustered !== undefined && !(Array.isArray(clustered) && clustered.every(nonEmpty))) {
67
- flag('`clusteredBy` is a list of column names');
64
+ for (const field of ['partitionedBy', 'clusteredBy']) {
65
+ const columns = def[field];
66
+ if (columns !== undefined && !(Array.isArray(columns) && columns.every(nonEmpty))) {
67
+ flag(`\`${field}\` is a list of column names`);
68
+ }
68
69
  }
69
70
  return out;
70
71
  }
package/dist/sql.d.ts CHANGED
@@ -53,12 +53,15 @@ export declare class GoogleSqlQuery extends QueryExpression {
53
53
  constructor({ ctes, queryExpression, orderByLine, limitOffset, pipes }: GoogleSqlQueryProps);
54
54
  toLine(): Line;
55
55
  }
56
+ /** A step of `pipe`: a `|>` operator, or a function from the query built so far to the next query. */
57
+ export type PipeStep = Pipe | ((query: QueryExpression) => QueryExpression);
56
58
  /**
57
- * Apply `|>` steps to a query. Pipe syntax is GoogleSQL's, so the result is a GoogleSqlQuery. A GoogleSqlQuery takes
58
- * the steps on its own pipe list (only its outer wrapper is flattened); anything else is wrapped. The input is not
59
- * changed.
59
+ * Apply steps to a query, left to right. `|>` operators are GoogleSQL's pipe syntax, so a call of only those returns a
60
+ * GoogleSqlQuery; a function step gets the query built so far and its result is what later steps apply to. The input
61
+ * is not changed.
60
62
  */
61
63
  export declare function pipe(query: QueryExpression, ...steps: Pipe[]): GoogleSqlQuery;
64
+ export declare function pipe(query: QueryExpression, ...steps: PipeStep[]): QueryExpression;
62
65
  type SqlInterp = string | number | Line | SqlSegment;
63
66
  /**
64
67
  * Tagged template for raw SQL fragments.
package/dist/sql.js CHANGED
@@ -119,22 +119,32 @@ export class GoogleSqlQuery extends QueryExpression {
119
119
  ]);
120
120
  }
121
121
  }
122
- /**
123
- * Apply `|>` steps to a query. Pipe syntax is GoogleSQL's, so the result is a GoogleSqlQuery. A GoogleSqlQuery takes
124
- * the steps on its own pipe list (only its outer wrapper is flattened); anything else is wrapped. The input is not
125
- * changed.
126
- */
127
- export function pipe(query, ...steps) {
122
+ /** A GoogleSqlQuery takes the pipes on its own pipe list (only its outer wrapper is flattened); anything else is wrapped. */
123
+ function appendPipes(query, pipes) {
128
124
  if (query instanceof GoogleSqlQuery) {
129
125
  return new GoogleSqlQuery({
130
126
  ctes: query.ctes,
131
127
  queryExpression: query.queryExpression,
132
128
  orderByLine: query.orderByLine,
133
129
  limitOffset: query.limitOffset,
134
- pipes: [...query.pipes, ...steps],
130
+ pipes: [...query.pipes, ...pipes],
135
131
  });
136
132
  }
137
- return new GoogleSqlQuery({ queryExpression: query, pipes: steps });
133
+ return new GoogleSqlQuery({ queryExpression: query, pipes });
134
+ }
135
+ export function pipe(query, ...steps) {
136
+ let result = query;
137
+ let pending = [];
138
+ for (const step of steps) {
139
+ if (step instanceof Pipe) {
140
+ pending.push(step);
141
+ continue;
142
+ }
143
+ result = step(pending.length > 0 ? appendPipes(result, pending) : result);
144
+ pending = [];
145
+ }
146
+ const endsWithFunction = steps.length > 0 && !(steps[steps.length - 1] instanceof Pipe);
147
+ return endsWithFunction ? result : appendPipes(result, pending);
138
148
  }
139
149
  /**
140
150
  * Tagged template for raw SQL fragments.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leuduan/block",
3
- "version": "2.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "SQL builder and renderer for GoogleSQL",
5
5
  "license": "ISC",
6
6
  "author": "Duan Leu",