@leuduan/block 2.0.0 → 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/README.md CHANGED
@@ -18,16 +18,19 @@ literals (`e.literalString`, the typed date and time literals) and quoted identi
18
18
  are escaped with GoogleSQL's backslash escapes.
19
19
 
20
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. 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
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
24
27
  (`BuiltDataset`: the query, its parameters, the results it referenced, the assets it loaded). A failure inside a
25
28
  referenced dataset is rethrown at the `ctx.ref` call with the original as its `cause`.
26
29
 
27
30
  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
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
31
34
  `e.ident(name)` for identifiers. `create_table` and the ANSI dialect (`dbox.block.ansi`) are not ported.
32
35
 
33
36
  ## Use
@@ -49,28 +52,19 @@ duplicates.format() // FROM `my-project.analytics.events` |> SELECT ...
49
52
  Datasets, built standalone:
50
53
 
51
54
  ```ts
52
- import { datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
55
+ import { Dataset, datasetBuilder, define, p, pipe, sql } from '@leuduan/block'
53
56
 
54
- const EVENTS = define({ kind: 'table', description: 'Raw events.', grain: 'one row per event' })
57
+ const EVENTS = define({ kind: 'table', id: ['analytics', 'events'], description: 'Raw events.' })
55
58
  const recent = define({
56
59
  kind: 'query',
60
+ id: ['analytics', 'recent_events'],
57
61
  description: 'Events since a day.',
58
- grain: 'one row per event',
59
62
  params: { day: { type: 'DATE', default: '2026-01-01' } },
60
63
  build: (ctx) => pipe(ctx.ref(EVENTS), p.where(sql`day >= ${ctx.param('day')}`)),
61
64
  })
62
65
 
63
- const names = new Map([
64
- [EVENTS, 'events'],
65
- [recent, 'recent'],
66
- ])
67
66
  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',
67
+ tableName: (dataset) => `my-project.${Dataset.definitionOf(dataset).id.join('.')}`,
74
68
  asset: (file) => {
75
69
  throw new Error(`no assets here: ${file}`)
76
70
  },
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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leuduan/block",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "SQL builder and renderer for GoogleSQL",
5
5
  "license": "ISC",
6
6
  "author": "Duan Leu",