dataform-sqlx-lint 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (16) hide show
  1. {dataform_sqlx_lint-0.1.1/src/dataform_sqlx_lint.egg-info → dataform_sqlx_lint-0.2.0}/PKG-INFO +9 -3
  2. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/README.md +8 -2
  3. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/pyproject.toml +1 -1
  4. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/src/dataform_sqlx_lint/__init__.py +1 -1
  5. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/src/dataform_sqlx_lint/config.py +13 -1
  6. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/src/dataform_sqlx_lint/linter.py +17 -0
  7. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0/src/dataform_sqlx_lint.egg-info}/PKG-INFO +9 -3
  8. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/tests/test_config.py +4 -0
  9. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/tests/test_lint.py +71 -0
  10. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/LICENSE +0 -0
  11. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/setup.cfg +0 -0
  12. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/src/dataform_sqlx_lint/cli.py +0 -0
  13. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/src/dataform_sqlx_lint.egg-info/SOURCES.txt +0 -0
  14. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/src/dataform_sqlx_lint.egg-info/dependency_links.txt +0 -0
  15. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/src/dataform_sqlx_lint.egg-info/entry_points.txt +0 -0
  16. {dataform_sqlx_lint-0.1.1 → dataform_sqlx_lint-0.2.0}/src/dataform_sqlx_lint.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataform-sqlx-lint
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Convention linter for Dataform .sqlx files — checks the config-block and project conventions that SQL linters cannot see
5
5
  Author-email: Ivan Histand <ihistand@rotoplas.com>
6
6
  License-Expression: MIT
@@ -42,6 +42,7 @@ pip install dataform-sqlx-lint
42
42
  | E007 | on* | configurable per-directory naming/type policies (*no-op until policies are configured) |
43
43
  | W008 | opt-in | `post_operations {}` placed before the main SELECT (style preference; Dataform accepts either) |
44
44
  | E010 | on | every determinable output column appears in `columns: {}` — parses the main SELECT conservatively (unparseable expressions are skipped, never false-flagged) and follows `select *` through a single plain `${ref()}` into the upstream file |
45
+ | E011 | on* | `FOREIGN KEY` written by hand in `post_operations` under configured paths — for projects that declare keys once in a map and generate both the DDL and the referential assertions from it (*no-op until `foreign_key_paths` is configured) |
45
46
 
46
47
  Why E010 matters: `columns: {}` is what Dataform writes to BigQuery column
47
48
  descriptions — the metadata data catalogs, BI tools, and AI/conversational
@@ -78,6 +79,8 @@ documented_types = ["table", "view", "incremental", "declaration"] # E002
78
79
  coverage_paths = ["definitions/output/"] # E010 scope; empty = everywhere
79
80
  enable = ["E005", "W008"] # switch on opt-in rules
80
81
  disable = ["E004"] # switch off default rules
82
+ foreign_key_paths = ["definitions/output/"] # E011 scope; empty = rule off
83
+ foreign_key_hint = "includes/keys.js" # named in E011's message
81
84
 
82
85
  [[dir_policies]] # E007 (repeatable)
83
86
  path_contains = "definitions/output/looker/"
@@ -118,13 +121,16 @@ Suppress with a reason, sparingly — the convention is usually the fix.
118
121
  [Agent Skill](https://agentskills.io/) — instructions that teach AI coding
119
122
  agents (Claude Code, Gemini CLI, Cursor, or any tool supporting the open
120
123
  skills format) to run this linter on every `.sqlx` file they create or
121
- modify. Install it by copying the folder into your agent's skills directory.
124
+ modify. Install it by copying the folder into your agent's skills directory —
125
+ or paste one of the ready-made prompts in
126
+ [skills/dataform-sqlx-lint/INSTALL_PROMPTS.md](https://github.com/acuantia/dataform-sqlx-lint/blob/main/skills/dataform-sqlx-lint/INSTALL_PROMPTS.md)
127
+ and let the agent install itself.
122
128
 
123
129
  ## Development
124
130
 
125
131
  ```bash
126
132
  python3 -m venv .venv && .venv/bin/pip install -e . pytest
127
- .venv/bin/pytest # 53 tests
133
+ .venv/bin/pytest # 59 tests
128
134
  ```
129
135
 
130
136
  ## License
@@ -25,6 +25,7 @@ pip install dataform-sqlx-lint
25
25
  | E007 | on* | configurable per-directory naming/type policies (*no-op until policies are configured) |
26
26
  | W008 | opt-in | `post_operations {}` placed before the main SELECT (style preference; Dataform accepts either) |
27
27
  | E010 | on | every determinable output column appears in `columns: {}` — parses the main SELECT conservatively (unparseable expressions are skipped, never false-flagged) and follows `select *` through a single plain `${ref()}` into the upstream file |
28
+ | E011 | on* | `FOREIGN KEY` written by hand in `post_operations` under configured paths — for projects that declare keys once in a map and generate both the DDL and the referential assertions from it (*no-op until `foreign_key_paths` is configured) |
28
29
 
29
30
  Why E010 matters: `columns: {}` is what Dataform writes to BigQuery column
30
31
  descriptions — the metadata data catalogs, BI tools, and AI/conversational
@@ -61,6 +62,8 @@ documented_types = ["table", "view", "incremental", "declaration"] # E002
61
62
  coverage_paths = ["definitions/output/"] # E010 scope; empty = everywhere
62
63
  enable = ["E005", "W008"] # switch on opt-in rules
63
64
  disable = ["E004"] # switch off default rules
65
+ foreign_key_paths = ["definitions/output/"] # E011 scope; empty = rule off
66
+ foreign_key_hint = "includes/keys.js" # named in E011's message
64
67
 
65
68
  [[dir_policies]] # E007 (repeatable)
66
69
  path_contains = "definitions/output/looker/"
@@ -101,13 +104,16 @@ Suppress with a reason, sparingly — the convention is usually the fix.
101
104
  [Agent Skill](https://agentskills.io/) — instructions that teach AI coding
102
105
  agents (Claude Code, Gemini CLI, Cursor, or any tool supporting the open
103
106
  skills format) to run this linter on every `.sqlx` file they create or
104
- modify. Install it by copying the folder into your agent's skills directory.
107
+ modify. Install it by copying the folder into your agent's skills directory —
108
+ or paste one of the ready-made prompts in
109
+ [skills/dataform-sqlx-lint/INSTALL_PROMPTS.md](https://github.com/acuantia/dataform-sqlx-lint/blob/main/skills/dataform-sqlx-lint/INSTALL_PROMPTS.md)
110
+ and let the agent install itself.
105
111
 
106
112
  ## Development
107
113
 
108
114
  ```bash
109
115
  python3 -m venv .venv && .venv/bin/pip install -e . pytest
110
- .venv/bin/pytest # 53 tests
116
+ .venv/bin/pytest # 59 tests
111
117
  ```
112
118
 
113
119
  ## License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dataform-sqlx-lint"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Convention linter for Dataform .sqlx files — checks the config-block and project conventions that SQL linters cannot see"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,4 +4,4 @@ from .config import Config, DirPolicy, load_config
4
4
  from .linter import Finding, lint_file, lint_text
5
5
 
6
6
  __all__ = ["Config", "DirPolicy", "Finding", "lint_file", "lint_text", "load_config"]
7
- __version__ = "0.1.1"
7
+ __version__ = "0.2.0"
@@ -11,7 +11,7 @@ from dataclasses import dataclass, field
11
11
  from pathlib import Path
12
12
 
13
13
  #: Rules that run unless disabled.
14
- DEFAULT_ENABLED = {"E001", "E002", "E003", "E004", "E006", "E007", "E010"}
14
+ DEFAULT_ENABLED = {"E001", "E002", "E003", "E004", "E006", "E007", "E010", "E011"}
15
15
  #: Opt-in rules (house-style checks): enable via `enable = [...]`.
16
16
  OPT_IN = {"E005", "W008"}
17
17
 
@@ -35,6 +35,10 @@ class Config:
35
35
  #: E010 applies only to files whose path contains one of these; empty = all.
36
36
  coverage_paths: list[str] = field(default_factory=list)
37
37
  dir_policies: list[DirPolicy] = field(default_factory=list)
38
+ #: E011 applies only to files whose path contains one of these; empty = rule is a no-op.
39
+ foreign_key_paths: list[str] = field(default_factory=list)
40
+ #: Where E011 tells the author to declare the key instead (named in the message).
41
+ foreign_key_hint: str | None = None
38
42
  enabled_extra: set[str] = field(default_factory=set)
39
43
  disabled: set[str] = field(default_factory=set)
40
44
 
@@ -51,6 +55,8 @@ class Config:
51
55
  and self.documented_types == other.documented_types
52
56
  and self.coverage_paths == other.coverage_paths
53
57
  and self.dir_policies == other.dir_policies
58
+ and self.foreign_key_paths == other.foreign_key_paths
59
+ and self.foreign_key_hint == other.foreign_key_hint
54
60
  and self.enabled_extra == other.enabled_extra
55
61
  and self.disabled == other.disabled
56
62
  )
@@ -61,6 +67,8 @@ _KNOWN_KEYS = {
61
67
  "documented_types",
62
68
  "coverage_paths",
63
69
  "dir_policies",
70
+ "foreign_key_paths",
71
+ "foreign_key_hint",
64
72
  "enable",
65
73
  "disable",
66
74
  }
@@ -88,6 +96,10 @@ def _from_dict(raw: dict) -> Config:
88
96
  kwargs["documented_types"] = set(raw["documented_types"])
89
97
  if "coverage_paths" in raw:
90
98
  kwargs["coverage_paths"] = list(raw["coverage_paths"])
99
+ if "foreign_key_paths" in raw:
100
+ kwargs["foreign_key_paths"] = list(raw["foreign_key_paths"])
101
+ if "foreign_key_hint" in raw:
102
+ kwargs["foreign_key_hint"] = str(raw["foreign_key_hint"])
91
103
  return Config(
92
104
  dir_policies=policies,
93
105
  enabled_extra=set(raw.get("enable", [])),
@@ -12,6 +12,7 @@ offending line or `-- sqlx-lint: disable-file=E006` anywhere in the file):
12
12
  E007 directory policy violation (configured prefix/type per path)
13
13
  W008 post_operations block appears before the main SELECT [opt-in]
14
14
  E010 columns:{} does not cover every determinable output column
15
+ E011 FOREIGN KEY written by hand in post_operations (configured paths) [no-op until configured]
15
16
  """
16
17
 
17
18
  from __future__ import annotations
@@ -358,6 +359,22 @@ def lint_text(text, path, config: Config | None = None, resolver=None):
358
359
  f"column(s): {shown}{more}",
359
360
  )
360
361
 
362
+ # --- E011: hand-written foreign keys in post_operations (configured paths) ---
363
+ # Projects that generate FK DDL from one declared map (so the constraint and its
364
+ # referential test cannot drift apart) flag any ADD CONSTRAINT ... FOREIGN KEY that
365
+ # bypasses the map. Comments are stripped first, so a note like
366
+ # "-- No FK to looker_product" never trips it.
367
+ if cfg.foreign_key_paths and any(p in path for p in cfg.foreign_key_paths):
368
+ where = cfg.foreign_key_hint or "the project's foreign-key map"
369
+ for fm in re.finditer(r"\bFOREIGN\s+KEY\b", clean_body, re.I):
370
+ add(
371
+ "E011",
372
+ "error",
373
+ _line_of(text, body_offset + fm.start()),
374
+ f"foreign key written by hand in post_operations; declare it in "
375
+ f"{where} and let the map's helper emit the DDL",
376
+ )
377
+
361
378
  # --- W008 (opt-in): post_operations placement ---
362
379
  pm = re.search(r"\bpost_operations\s*{", body)
363
380
  if pm:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataform-sqlx-lint
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Convention linter for Dataform .sqlx files — checks the config-block and project conventions that SQL linters cannot see
5
5
  Author-email: Ivan Histand <ihistand@rotoplas.com>
6
6
  License-Expression: MIT
@@ -42,6 +42,7 @@ pip install dataform-sqlx-lint
42
42
  | E007 | on* | configurable per-directory naming/type policies (*no-op until policies are configured) |
43
43
  | W008 | opt-in | `post_operations {}` placed before the main SELECT (style preference; Dataform accepts either) |
44
44
  | E010 | on | every determinable output column appears in `columns: {}` — parses the main SELECT conservatively (unparseable expressions are skipped, never false-flagged) and follows `select *` through a single plain `${ref()}` into the upstream file |
45
+ | E011 | on* | `FOREIGN KEY` written by hand in `post_operations` under configured paths — for projects that declare keys once in a map and generate both the DDL and the referential assertions from it (*no-op until `foreign_key_paths` is configured) |
45
46
 
46
47
  Why E010 matters: `columns: {}` is what Dataform writes to BigQuery column
47
48
  descriptions — the metadata data catalogs, BI tools, and AI/conversational
@@ -78,6 +79,8 @@ documented_types = ["table", "view", "incremental", "declaration"] # E002
78
79
  coverage_paths = ["definitions/output/"] # E010 scope; empty = everywhere
79
80
  enable = ["E005", "W008"] # switch on opt-in rules
80
81
  disable = ["E004"] # switch off default rules
82
+ foreign_key_paths = ["definitions/output/"] # E011 scope; empty = rule off
83
+ foreign_key_hint = "includes/keys.js" # named in E011's message
81
84
 
82
85
  [[dir_policies]] # E007 (repeatable)
83
86
  path_contains = "definitions/output/looker/"
@@ -118,13 +121,16 @@ Suppress with a reason, sparingly — the convention is usually the fix.
118
121
  [Agent Skill](https://agentskills.io/) — instructions that teach AI coding
119
122
  agents (Claude Code, Gemini CLI, Cursor, or any tool supporting the open
120
123
  skills format) to run this linter on every `.sqlx` file they create or
121
- modify. Install it by copying the folder into your agent's skills directory.
124
+ modify. Install it by copying the folder into your agent's skills directory —
125
+ or paste one of the ready-made prompts in
126
+ [skills/dataform-sqlx-lint/INSTALL_PROMPTS.md](https://github.com/acuantia/dataform-sqlx-lint/blob/main/skills/dataform-sqlx-lint/INSTALL_PROMPTS.md)
127
+ and let the agent install itself.
122
128
 
123
129
  ## Development
124
130
 
125
131
  ```bash
126
132
  python3 -m venv .venv && .venv/bin/pip install -e . pytest
127
- .venv/bin/pytest # 53 tests
133
+ .venv/bin/pytest # 59 tests
128
134
  ```
129
135
 
130
136
  ## License
@@ -7,6 +7,8 @@ schema_suffixes = ["_prod", "_dev", "_staging"]
7
7
  coverage_paths = ["definitions/output/looker/"]
8
8
  enable = ["E005", "W008"]
9
9
  disable = ["E004"]
10
+ foreign_key_paths = ["definitions/output/looker/"]
11
+ foreign_key_hint = "includes/keys.js"
10
12
 
11
13
  [[dir_policies]]
12
14
  path_contains = "definitions/output/looker/"
@@ -27,6 +29,8 @@ def test_load_standalone_toml(tmp_path):
27
29
  assert cfg.coverage_paths == ["definitions/output/looker/"]
28
30
  assert "E005" in cfg.enabled_extra and "W008" in cfg.enabled_extra
29
31
  assert "E004" in cfg.disabled
32
+ assert cfg.foreign_key_paths == ["definitions/output/looker/"]
33
+ assert cfg.foreign_key_hint == "includes/keys.js"
30
34
  assert len(cfg.dir_policies) == 2
31
35
  assert cfg.dir_policies[0].require_prefix == "looker_"
32
36
  assert cfg.dir_policies[1].severity == "warning"
@@ -369,3 +369,74 @@ class TestE010ColumnCoverage:
369
369
  cfg = Config(disabled={"E010"})
370
370
  text = self._table('a: "A."', 'select a, b from ${ref("t")}')
371
371
  assert "E010" not in codes(lint_text(text, PATH, config=cfg))
372
+
373
+
374
+ class TestE011HandWrittenForeignKeys:
375
+ FK_PATH = "definitions/output/looker/looker_orders.sqlx"
376
+ CFG = Config(
377
+ foreign_key_paths=["definitions/output/looker/"],
378
+ foreign_key_hint="includes/keys.js",
379
+ )
380
+
381
+ def _table(self, post_ops):
382
+ return (
383
+ 'config {\n type: "table",\n schema: "looker",\n'
384
+ ' columns: { order_id: "Order." }\n}\n\n'
385
+ 'select order_id from ${ref("vw_orders")}\n\n'
386
+ f"post_operations {{\n ALTER TABLE ${{self()}}\n{post_ops}\n}}\n"
387
+ )
388
+
389
+ HAND_WRITTEN = (
390
+ " ADD PRIMARY KEY (order_id) NOT ENFORCED,\n"
391
+ " ADD CONSTRAINT o_to_c FOREIGN KEY (customer_id) "
392
+ 'REFERENCES ${ref("looker_customer")}(customer_id) NOT ENFORCED;'
393
+ )
394
+ GENERATED = (
395
+ " ADD PRIMARY KEY (order_id) NOT ENFORCED,\n"
396
+ " -- No FK to looker_product: kept ids of deleted products.\n"
397
+ " ${keys.fkAdd(self, ref)};"
398
+ )
399
+
400
+ def test_hand_written_fk_flagged_in_scope(self):
401
+ found = [
402
+ f
403
+ for f in lint_text(self._table(self.HAND_WRITTEN), self.FK_PATH, config=self.CFG)
404
+ if f.code == "E011"
405
+ ]
406
+ assert len(found) == 1
407
+ assert "includes/keys.js" in found[0].message
408
+ assert found[0].line == 12
409
+
410
+ def test_generated_fk_and_comments_pass(self):
411
+ assert "E011" not in codes(
412
+ lint_text(self._table(self.GENERATED), self.FK_PATH, config=self.CFG)
413
+ )
414
+
415
+ def test_out_of_scope_path_not_flagged(self):
416
+ assert "E011" not in codes(
417
+ lint_text(
418
+ self._table(self.HAND_WRITTEN),
419
+ "definitions/output/reports/orders.sqlx",
420
+ config=self.CFG,
421
+ )
422
+ )
423
+
424
+ def test_no_op_without_configured_paths(self):
425
+ assert "E011" not in codes(lint_text(self._table(self.HAND_WRITTEN), self.FK_PATH))
426
+
427
+ def test_default_hint_when_unconfigured(self):
428
+ cfg = Config(foreign_key_paths=["definitions/output/looker/"])
429
+ found = [
430
+ f
431
+ for f in lint_text(self._table(self.HAND_WRITTEN), self.FK_PATH, config=cfg)
432
+ if f.code == "E011"
433
+ ]
434
+ assert found and "foreign-key map" in found[0].message
435
+
436
+ def test_line_suppression(self):
437
+ text = self._table(
438
+ self.HAND_WRITTEN.replace(
439
+ "NOT ENFORCED;", "NOT ENFORCED; -- sqlx-lint: disable=E011 (legacy)"
440
+ )
441
+ )
442
+ assert "E011" not in codes(lint_text(text, self.FK_PATH, config=self.CFG))