model2data 1.5.0__tar.gz → 1.7.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.
- {model2data-1.5.0/model2data.egg-info → model2data-1.7.0}/PKG-INFO +1 -1
- {model2data-1.5.0 → model2data-1.7.0}/README.md +38 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/generate/faker.py +144 -11
- {model2data-1.5.0 → model2data-1.7.0}/model2data/generate/hints.py +101 -4
- {model2data-1.5.0 → model2data-1.7.0/model2data.egg-info}/PKG-INFO +1 -1
- {model2data-1.5.0 → model2data-1.7.0}/model2data.egg-info/SOURCES.txt +2 -0
- {model2data-1.5.0 → model2data-1.7.0}/pyproject.toml +1 -1
- model2data-1.7.0/tests/test_column_time_hints.py +241 -0
- model2data-1.7.0/tests/test_distributions.py +258 -0
- {model2data-1.5.0 → model2data-1.7.0}/LICENSE +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/README_PYPI.md +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/__init__.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/cli.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/dbt/__init__.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/dbt/project.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/dbt/tests.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/generate/__init__.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/generate/core.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/generate/options.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/generate/relationships.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/generate/timeline.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/parse/__init__.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/parse/dbml.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data/utils.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data.egg-info/dependency_links.txt +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data.egg-info/entry_points.txt +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data.egg-info/requires.txt +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/model2data.egg-info/top_level.txt +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/setup.cfg +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_as_of_anchor.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_cli.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_coverage_gaps.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_dbml_parser.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_dbml_parser_fuzz.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_dbt_integration.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_dbt_naming.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_dbt_project.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_dbt_tests.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_faker_name_inference.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_generation.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_options.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_release_stress.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_row_identity.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_shaping.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_table_seeds.py +0 -0
- {model2data-1.5.0 → model2data-1.7.0}/tests/test_timeline.py +0 -0
|
@@ -184,6 +184,24 @@ doesn't say what it depends on can say so explicitly with an `after` note:
|
|
|
184
184
|
shipped_at timestamp [note: '{"after": "ordered_at"}']
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
+
The flags above shape every date and timestamp column the same way, run-wide. `business_hours`,
|
|
188
|
+
`growth`, and `seasonality` column note hints override that for one column at a time — the whole
|
|
189
|
+
point being a run can be uniform everywhere except the one column that needs shaping, or shaped
|
|
190
|
+
everywhere except the one column that shouldn't be:
|
|
191
|
+
|
|
192
|
+
```dbml
|
|
193
|
+
Table orders {
|
|
194
|
+
id int [pk]
|
|
195
|
+
created_at timestamp [note: '{"business_hours": true, "growth": 0.4}']
|
|
196
|
+
refunded_at timestamp [note: '{"growth": 0}']
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Here `created_at` gets business hours and growth even on an otherwise-uniform run, while
|
|
201
|
+
`refunded_at` stays flat even under `--growth 0.5` — each hint only replaces the fields it names,
|
|
202
|
+
so a partial hint like `{"growth": 0}` leaves that column's `business_hours`/`seasonality` at
|
|
203
|
+
whatever the run-level flags set.
|
|
204
|
+
|
|
187
205
|
### Shape how the data is spread
|
|
188
206
|
|
|
189
207
|
By default every parent row is equally likely to be picked for a child row, and every column
|
|
@@ -214,6 +232,26 @@ non-null rows a boolean column comes back `true`. `null_rate` replaces the colum
|
|
|
214
232
|
fraction outright. `distinct` draws the column's values from a fixed-size pool instead of a fresh
|
|
215
233
|
value per row — a `shipping_city` most warehouses only ever see a handful of.
|
|
216
234
|
|
|
235
|
+
### Shape a number's distribution
|
|
236
|
+
|
|
237
|
+
`min`/`max` alone only ever drew uniformly between them. A `distribution` note hint on an
|
|
238
|
+
integer or decimal column picks a different shape instead:
|
|
239
|
+
|
|
240
|
+
```dbml
|
|
241
|
+
Table orders {
|
|
242
|
+
id int [pk]
|
|
243
|
+
total_amount numeric [note: '{"distribution": "lognormal", "median": 80, "spread": 0.6, "min": 5}']
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`normal` takes `mean` (the centre) and `stddev` (the spread); `lognormal` takes `median` (the
|
|
248
|
+
typical value) and `spread` (how heavy the tail is — 0.3 is mild, 1.0 is heavy); `exponential`
|
|
249
|
+
takes `mean` (the average). Any left unset default to the midpoint of the column's effective
|
|
250
|
+
`min`/`max` (or `stddev` = range / 6, `spread` = 0.5). `min`/`max` still clip the result — a
|
|
251
|
+
`normal` centred near an edge redraws a bounded number of times before clamping, so it never
|
|
252
|
+
loops forever and never crosses the bound. Leaving `distribution` out, or setting it to
|
|
253
|
+
`"uniform"`, is exactly today's behaviour.
|
|
254
|
+
|
|
217
255
|
Run dbt to load, transform, and test the data:
|
|
218
256
|
|
|
219
257
|
```bash
|
|
@@ -12,7 +12,7 @@ from typing import Callable, Optional, Union
|
|
|
12
12
|
import pandas as pd
|
|
13
13
|
from faker import Faker
|
|
14
14
|
|
|
15
|
-
from model2data.generate.options import TimeProfile
|
|
15
|
+
from model2data.generate.options import UNIFORM, TimeProfile
|
|
16
16
|
from model2data.generate.timeline import weighted_dates, weighted_timestamps
|
|
17
17
|
from model2data.parse.dbml import ColumnDef
|
|
18
18
|
|
|
@@ -505,6 +505,31 @@ def _infer_by_type(base_type: str) -> Optional[_Provider]:
|
|
|
505
505
|
return lambda: fake.format(base_type)
|
|
506
506
|
|
|
507
507
|
|
|
508
|
+
def _column_time_profile(
|
|
509
|
+
column: ColumnDef, time_profile: Optional[TimeProfile]
|
|
510
|
+
) -> Optional[TimeProfile]:
|
|
511
|
+
"""The run-level `time_profile` with this column's note hints applied on top.
|
|
512
|
+
|
|
513
|
+
Mirrors the `skew` override on the FK branch, one level up: a note doesn't
|
|
514
|
+
replace the run's profile, it patches only the fields it names (via
|
|
515
|
+
`dataclasses.replace`), so `{"growth": 0}` flattens one column of a
|
|
516
|
+
growing run while `business_hours`/`seasonality` stay exactly what the
|
|
517
|
+
run set. `validate_hints` has already confirmed the column is a date or
|
|
518
|
+
timestamp column and that each hint present is well-typed, so this does
|
|
519
|
+
no validation of its own -- a column with no hint gets `time_profile`
|
|
520
|
+
back unchanged, including a bare `None`, so the uniform path stays
|
|
521
|
+
untouched.
|
|
522
|
+
"""
|
|
523
|
+
note = column.note or {}
|
|
524
|
+
overrides = {
|
|
525
|
+
key: note[key] for key in ("business_hours", "growth", "seasonality") if key in note
|
|
526
|
+
}
|
|
527
|
+
if not overrides:
|
|
528
|
+
return time_profile
|
|
529
|
+
base = time_profile if time_profile is not None else UNIFORM
|
|
530
|
+
return replace(base, **overrides)
|
|
531
|
+
|
|
532
|
+
|
|
508
533
|
def _generate_dates(row_count: int, as_of: AsOf, time_profile: Optional[TimeProfile]) -> list:
|
|
509
534
|
"""The date branch's values: shaped by `time_profile` when it isn't uniform.
|
|
510
535
|
|
|
@@ -640,6 +665,13 @@ def generate_column_values(
|
|
|
640
665
|
if column.note:
|
|
641
666
|
min_val = column.note.get("min")
|
|
642
667
|
max_val = column.note.get("max")
|
|
668
|
+
# The note's own min/max, before either numeric branch fills in its
|
|
669
|
+
# branch-specific default -- these are the only bounds a distribution
|
|
670
|
+
# hint clips against (see `_clipped_distribution_draw`). A column with no
|
|
671
|
+
# explicit min/max draws from an unclipped distribution even though the
|
|
672
|
+
# branch below still has an implicit default range for the *parameters*.
|
|
673
|
+
explicit_min, explicit_max = min_val, max_val
|
|
674
|
+
distribution = column_note.get("distribution", "uniform")
|
|
643
675
|
|
|
644
676
|
if fk_series is not None and not fk_series.empty:
|
|
645
677
|
# A plain branch of the same if/elif chain (rather than an early
|
|
@@ -697,7 +729,22 @@ def generate_column_values(
|
|
|
697
729
|
if max_val is None:
|
|
698
730
|
max_val = 100
|
|
699
731
|
|
|
700
|
-
if
|
|
732
|
+
if distribution != "uniform":
|
|
733
|
+
# A shaped column never needs the row_count-sized value space
|
|
734
|
+
# random.sample relies on below, so it always draws one value at
|
|
735
|
+
# a time -- unique or not -- and leans on _deduplicate the same
|
|
736
|
+
# way every other non-uniform-fast-path branch does.
|
|
737
|
+
params = _distribution_params(distribution, min_val, max_val, column_note)
|
|
738
|
+
|
|
739
|
+
def generator() -> int:
|
|
740
|
+
return round(
|
|
741
|
+
_clipped_distribution_draw(distribution, params, explicit_min, explicit_max)
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
values = [generator() for _ in range(row_count)]
|
|
745
|
+
if ensure_unique:
|
|
746
|
+
values = _deduplicate(values, generator, column_name=unique_label)
|
|
747
|
+
elif ensure_unique:
|
|
701
748
|
if not had_explicit_range:
|
|
702
749
|
# No user-specified range: widen the default so there's
|
|
703
750
|
# always enough headroom for `row_count` unique PK values.
|
|
@@ -728,13 +775,27 @@ def generate_column_values(
|
|
|
728
775
|
min_val = 0
|
|
729
776
|
if max_val is None:
|
|
730
777
|
max_val = 10_000
|
|
731
|
-
|
|
732
|
-
if
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
778
|
+
|
|
779
|
+
if distribution != "uniform":
|
|
780
|
+
params = _distribution_params(distribution, min_val, max_val, column_note)
|
|
781
|
+
|
|
782
|
+
def generator() -> float:
|
|
783
|
+
return round(
|
|
784
|
+
_clipped_distribution_draw(distribution, params, explicit_min, explicit_max),
|
|
785
|
+
2,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
values = [generator() for _ in range(row_count)]
|
|
789
|
+
if ensure_unique:
|
|
790
|
+
values = _deduplicate(values, generator, column_name=unique_label)
|
|
791
|
+
else:
|
|
792
|
+
values = [round(random.uniform(min_val, max_val), 2) for _ in range(row_count)]
|
|
793
|
+
if ensure_unique:
|
|
794
|
+
values = _deduplicate(
|
|
795
|
+
values,
|
|
796
|
+
lambda: round(random.uniform(min_val, max_val), 2),
|
|
797
|
+
column_name=unique_label,
|
|
798
|
+
)
|
|
738
799
|
|
|
739
800
|
# -----------------------------------------------------
|
|
740
801
|
# Booleans
|
|
@@ -750,13 +811,13 @@ def generate_column_values(
|
|
|
750
811
|
# Dates
|
|
751
812
|
# -----------------------------------------------------
|
|
752
813
|
elif "date" in base_type and "time" not in base_type:
|
|
753
|
-
values = _generate_dates(row_count, as_of, time_profile)
|
|
814
|
+
values = _generate_dates(row_count, as_of, _column_time_profile(column, time_profile))
|
|
754
815
|
|
|
755
816
|
elif "time" in base_type and "stamp" not in base_type:
|
|
756
817
|
values = [fake.time() for _ in range(row_count)]
|
|
757
818
|
|
|
758
819
|
elif any(key in base_type for key in ["timestamp", "datetime"]):
|
|
759
|
-
values = _generate_timestamps(row_count, as_of, time_profile)
|
|
820
|
+
values = _generate_timestamps(row_count, as_of, _column_time_profile(column, time_profile))
|
|
760
821
|
|
|
761
822
|
# -----------------------------------------------------
|
|
762
823
|
# Untyped / generic string columns: honour a type that names
|
|
@@ -799,6 +860,78 @@ def generate_column_values(
|
|
|
799
860
|
# ---------------------------------------------------------
|
|
800
861
|
# Internal helpers
|
|
801
862
|
# ---------------------------------------------------------
|
|
863
|
+
# How many times a clipped draw redraws before giving up and clamping. Kept
|
|
864
|
+
# small and fixed rather than exposed as a hint: a normal whose mean sits
|
|
865
|
+
# well inside its min/max rarely needs a redraw at all, and a distribution
|
|
866
|
+
# whose parameters put most of its mass outside the bounds is a schema
|
|
867
|
+
# mistake no retry count fixes -- clamping is the honest fallback either way.
|
|
868
|
+
_MAX_DISTRIBUTION_REDRAWS = 20
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _distribution_params(
|
|
872
|
+
distribution: str, effective_min: float, effective_max: float, note: dict
|
|
873
|
+
) -> dict:
|
|
874
|
+
"""Fill in whichever of mean/stddev/median/spread `note` leaves unset.
|
|
875
|
+
|
|
876
|
+
`effective_min`/`effective_max` are the note's own `min`/`max` when given,
|
|
877
|
+
else the calling branch's default range (0-100 for integers, 0-10,000 for
|
|
878
|
+
decimals) -- ordinary enough a midpoint to centre an unparameterized
|
|
879
|
+
distribution on.
|
|
880
|
+
"""
|
|
881
|
+
midpoint = (effective_min + effective_max) / 2
|
|
882
|
+
if distribution == "normal":
|
|
883
|
+
return {
|
|
884
|
+
"mean": note.get("mean", midpoint),
|
|
885
|
+
"stddev": note.get("stddev", (effective_max - effective_min) / 6),
|
|
886
|
+
}
|
|
887
|
+
if distribution == "lognormal":
|
|
888
|
+
return {
|
|
889
|
+
"median": note.get("median", midpoint),
|
|
890
|
+
"spread": note.get("spread", 0.5),
|
|
891
|
+
}
|
|
892
|
+
# exponential: hints.py has already rejected anything else reaching here.
|
|
893
|
+
return {"mean": note.get("mean", midpoint)}
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _draw_distribution_value(distribution: str, params: dict) -> float:
|
|
897
|
+
"""One raw draw from `distribution`, unclipped and unrounded."""
|
|
898
|
+
if distribution == "normal":
|
|
899
|
+
return random.gauss(params["mean"], params["stddev"])
|
|
900
|
+
if distribution == "lognormal":
|
|
901
|
+
return random.lognormvariate(math.log(params["median"]), params["spread"])
|
|
902
|
+
return random.expovariate(1 / params["mean"]) # exponential
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _clipped_distribution_draw(
|
|
906
|
+
distribution: str,
|
|
907
|
+
params: dict,
|
|
908
|
+
clip_min: Optional[float],
|
|
909
|
+
clip_max: Optional[float],
|
|
910
|
+
) -> float:
|
|
911
|
+
"""Draw from `distribution`, redrawing out-of-bounds values before clamping.
|
|
912
|
+
|
|
913
|
+
A normal centred well inside [min, max] almost never needs the clamp; one
|
|
914
|
+
centred near an edge (mean 120, min 0) would otherwise pile values up at
|
|
915
|
+
the boundary, so a bounded number of redraws is tried first and only a
|
|
916
|
+
value still out of bounds after all of them gets clamped -- which also
|
|
917
|
+
keeps this from looping forever when min and max leave no room at all.
|
|
918
|
+
`clip_min`/`clip_max` are the note's own bounds (`None` when the column
|
|
919
|
+
didn't set one), never the branch's implicit default range.
|
|
920
|
+
"""
|
|
921
|
+
value = _draw_distribution_value(distribution, params)
|
|
922
|
+
attempts = 0
|
|
923
|
+
while attempts < _MAX_DISTRIBUTION_REDRAWS and (
|
|
924
|
+
(clip_min is not None and value < clip_min) or (clip_max is not None and value > clip_max)
|
|
925
|
+
):
|
|
926
|
+
value = _draw_distribution_value(distribution, params)
|
|
927
|
+
attempts += 1
|
|
928
|
+
if clip_min is not None and value < clip_min:
|
|
929
|
+
value = clip_min
|
|
930
|
+
if clip_max is not None and value > clip_max:
|
|
931
|
+
value = clip_max
|
|
932
|
+
return value
|
|
933
|
+
|
|
934
|
+
|
|
802
935
|
def _null_fraction_for(column: ColumnDef, row_count: int) -> float:
|
|
803
936
|
"""The fraction of `row_count` rows this column should turn null.
|
|
804
937
|
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
A note has always been either plain text (a comment, ignored by generation)
|
|
4
4
|
or a JSON object read for `min`/`max`. This module documents the rest of that
|
|
5
5
|
object's vocabulary -- `null_rate`, `weights`, `true_rate`, `distinct`, `skew`,
|
|
6
|
-
`after`
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
`after`, `business_hours`, `growth`, `seasonality`, `distribution` and its
|
|
7
|
+
parameters -- and checks it once, before a single row is generated, so a
|
|
8
|
+
typo'd enum value or a hint on the wrong kind of column fails with a message
|
|
9
|
+
naming the table and column rather than surfacing as a wrong-looking dataset
|
|
10
|
+
or a downstream dbt test failure.
|
|
10
11
|
|
|
11
12
|
| key | applies to | meaning |
|
|
12
13
|
|-------------|-----------------------------------------------|--------------------------------------------|
|
|
@@ -17,6 +18,14 @@ downstream dbt test failure.
|
|
|
17
18
|
| `distinct` | columns that aren't an FK, `pk`, `unique`, enum | positive integer: draw from a pool that size |
|
|
18
19
|
| `skew` | foreign-key columns | overrides the run-level `skew` for this column |
|
|
19
20
|
| `after` | date/timestamp columns | name of another date/timestamp column, read by the time-aware generator |
|
|
21
|
+
| `business_hours` | date/timestamp columns | overrides the run-level `TimeProfile.business_hours` for this column |
|
|
22
|
+
| `growth` | date/timestamp columns | overrides the run-level `TimeProfile.growth` for this column |
|
|
23
|
+
| `seasonality` | date/timestamp columns | overrides the run-level `TimeProfile.seasonality` for this column |
|
|
24
|
+
| `distribution` | numeric columns | draw shape: `uniform` (default), `normal`, `lognormal`, `exponential` |
|
|
25
|
+
| `mean` | numeric columns, with `distribution: normal` or `exponential` | normal: the centre; exponential: the average (scale) |
|
|
26
|
+
| `stddev` | numeric columns, with `distribution: normal` | normal: the spread |
|
|
27
|
+
| `median` | numeric columns, with `distribution: lognormal`| lognormal: the typical value, `exp(mu)` |
|
|
28
|
+
| `spread` | numeric columns, with `distribution: lognormal`| lognormal: sigma of the underlying normal (0.3 mild, 1.0 heavy tail) |
|
|
20
29
|
"""
|
|
21
30
|
|
|
22
31
|
from __future__ import annotations
|
|
@@ -92,6 +101,33 @@ def _is_temporal_type(base_type: str) -> bool:
|
|
|
92
101
|
return any(key in base_type for key in ("timestamp", "datetime"))
|
|
93
102
|
|
|
94
103
|
|
|
104
|
+
def _is_numeric_type(base_type: str) -> bool:
|
|
105
|
+
"""True for the integer and float/decimal types `min`/`max`/`distribution` apply to.
|
|
106
|
+
|
|
107
|
+
Mirrors generate.faker's own two numeric branches exactly, so a hint this
|
|
108
|
+
module accepts is guaranteed to land on a branch that reads it.
|
|
109
|
+
"""
|
|
110
|
+
return any(
|
|
111
|
+
key in base_type
|
|
112
|
+
for key in ("int", "integer", "bigint", "smallint", "decimal", "numeric", "float", "double")
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Distributions a numeric column's `distribution` hint may name.
|
|
117
|
+
_DISTRIBUTIONS = ("uniform", "normal", "lognormal", "exponential")
|
|
118
|
+
|
|
119
|
+
# Which distribution(s) each shape parameter is meaningful under -- checked
|
|
120
|
+
# against the note's own `distribution` (default "uniform" when absent), so
|
|
121
|
+
# a `stddev` with no `distribution` key is rejected the same as one paired
|
|
122
|
+
# with the wrong distribution.
|
|
123
|
+
_PARAM_DISTRIBUTIONS: dict[str, tuple[str, ...]] = {
|
|
124
|
+
"mean": ("normal", "exponential"),
|
|
125
|
+
"stddev": ("normal",),
|
|
126
|
+
"median": ("lognormal",),
|
|
127
|
+
"spread": ("lognormal",),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
95
131
|
def _validate_column_hints(
|
|
96
132
|
table_name: str,
|
|
97
133
|
column: ColumnDef,
|
|
@@ -107,6 +143,7 @@ def _validate_column_hints(
|
|
|
107
143
|
is_enum = bool(column.enum_values)
|
|
108
144
|
is_boolean = "boolean" in base_type or "bool" in base_type
|
|
109
145
|
is_temporal = _is_temporal_type(base_type)
|
|
146
|
+
is_numeric = _is_numeric_type(base_type)
|
|
110
147
|
is_nullable = not is_pk and "not null" not in column.settings
|
|
111
148
|
|
|
112
149
|
if "null_rate" in note:
|
|
@@ -152,6 +189,46 @@ def _validate_column_hints(
|
|
|
152
189
|
raise ValueError(f'{label}: "after" only applies to date/timestamp columns.')
|
|
153
190
|
_check_after(label, table_name, note["after"], columns_by_name)
|
|
154
191
|
|
|
192
|
+
if "business_hours" in note:
|
|
193
|
+
if not is_temporal:
|
|
194
|
+
raise ValueError(f'{label}: "business_hours" only applies to date/timestamp columns.')
|
|
195
|
+
_check_bool(label, "business_hours", note["business_hours"])
|
|
196
|
+
|
|
197
|
+
if "growth" in note:
|
|
198
|
+
if not is_temporal:
|
|
199
|
+
raise ValueError(f'{label}: "growth" only applies to date/timestamp columns.')
|
|
200
|
+
_check_growth(label, note["growth"])
|
|
201
|
+
|
|
202
|
+
if "seasonality" in note:
|
|
203
|
+
if not is_temporal:
|
|
204
|
+
raise ValueError(f'{label}: "seasonality" only applies to date/timestamp columns.')
|
|
205
|
+
_check_fraction(label, "seasonality", note["seasonality"])
|
|
206
|
+
|
|
207
|
+
if "distribution" in note:
|
|
208
|
+
if not is_numeric:
|
|
209
|
+
raise ValueError(f'{label}: "distribution" only applies to numeric columns.')
|
|
210
|
+
if note["distribution"] not in _DISTRIBUTIONS:
|
|
211
|
+
allowed = ", ".join(f'"{d}"' for d in _DISTRIBUTIONS)
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f'{label}: "distribution" must be one of {allowed} (got {note["distribution"]!r}).'
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# mean/stddev/median/spread only make sense alongside the distribution
|
|
217
|
+
# they shape, so each is checked against the note's own `distribution`
|
|
218
|
+
# (absent means "uniform", which none of them apply to either).
|
|
219
|
+
for param_key, required_distributions in _PARAM_DISTRIBUTIONS.items():
|
|
220
|
+
if param_key not in note:
|
|
221
|
+
continue
|
|
222
|
+
if not is_numeric:
|
|
223
|
+
raise ValueError(f'{label}: "{param_key}" only applies to numeric columns.')
|
|
224
|
+
if note.get("distribution") not in required_distributions:
|
|
225
|
+
options = " or ".join(f'"{d}"' for d in required_distributions)
|
|
226
|
+
raise ValueError(f'{label}: "{param_key}" only applies with "distribution": {options}.')
|
|
227
|
+
if param_key == "mean":
|
|
228
|
+
_check_number(label, param_key, note[param_key])
|
|
229
|
+
else:
|
|
230
|
+
_check_positive_number(label, param_key, note[param_key])
|
|
231
|
+
|
|
155
232
|
|
|
156
233
|
def _check_fraction(label: str, key: str, value: object) -> None:
|
|
157
234
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0.0 <= value <= 1.0:
|
|
@@ -163,6 +240,26 @@ def _check_positive_int(label: str, key: str, value: object) -> None:
|
|
|
163
240
|
raise ValueError(f'{label}: "{key}" must be a positive whole number (got {value!r}).')
|
|
164
241
|
|
|
165
242
|
|
|
243
|
+
def _check_number(label: str, key: str, value: object) -> None:
|
|
244
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
245
|
+
raise ValueError(f'{label}: "{key}" must be a number (got {value!r}).')
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _check_positive_number(label: str, key: str, value: object) -> None:
|
|
249
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
250
|
+
raise ValueError(f'{label}: "{key}" must be a number greater than 0 (got {value!r}).')
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_bool(label: str, key: str, value: object) -> None:
|
|
254
|
+
if not isinstance(value, bool):
|
|
255
|
+
raise ValueError(f'{label}: "{key}" must be true or false (got {value!r}).')
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _check_growth(label: str, value: object) -> None:
|
|
259
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < -1.0:
|
|
260
|
+
raise ValueError(f'{label}: "growth" must be -1.0 or more (got {value!r}).')
|
|
261
|
+
|
|
262
|
+
|
|
166
263
|
def _check_enum_weights(label: str, enum_values: list[str], weights: object) -> None:
|
|
167
264
|
if not isinstance(weights, dict) or not weights:
|
|
168
265
|
raise ValueError(
|
|
@@ -28,6 +28,7 @@ model2data/parse/__init__.py
|
|
|
28
28
|
model2data/parse/dbml.py
|
|
29
29
|
tests/test_as_of_anchor.py
|
|
30
30
|
tests/test_cli.py
|
|
31
|
+
tests/test_column_time_hints.py
|
|
31
32
|
tests/test_coverage_gaps.py
|
|
32
33
|
tests/test_dbml_parser.py
|
|
33
34
|
tests/test_dbml_parser_fuzz.py
|
|
@@ -35,6 +36,7 @@ tests/test_dbt_integration.py
|
|
|
35
36
|
tests/test_dbt_naming.py
|
|
36
37
|
tests/test_dbt_project.py
|
|
37
38
|
tests/test_dbt_tests.py
|
|
39
|
+
tests/test_distributions.py
|
|
38
40
|
tests/test_faker_name_inference.py
|
|
39
41
|
tests/test_generation.py
|
|
40
42
|
tests/test_options.py
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Per-column overrides of the run-level `TimeProfile`.
|
|
2
|
+
|
|
3
|
+
1.5.0 shaped *when* things happen for a whole run: business hours, growth,
|
|
4
|
+
seasonality all apply to every date/timestamp column alike. The product
|
|
5
|
+
ask this closes was literally "that changes for everything globally, can we
|
|
6
|
+
have that column per column?" -- so `business_hours`/`growth`/`seasonality`
|
|
7
|
+
column-note hints patch just the fields they name onto the run's profile for
|
|
8
|
+
that one column, the same way `skew` already overrides per foreign key (see
|
|
9
|
+
tests/test_shaping.py). Every statistical test here uses a fixed seed and a
|
|
10
|
+
generous tolerance so it never flakes; the exact bounds were chosen by
|
|
11
|
+
running the real implementation and leaving headroom, not derived
|
|
12
|
+
analytically.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from datetime import date, datetime, timedelta
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from model2data.generate.core import generate_data_from_dbml
|
|
20
|
+
from model2data.generate.options import TimeProfile
|
|
21
|
+
from model2data.parse.dbml import ColumnDef, TableDef
|
|
22
|
+
|
|
23
|
+
ANCHOR_DATE = date(2026, 3, 15)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _two_timestamp_schema(hinted_note=None, plain_note=None) -> dict[str, TableDef]:
|
|
27
|
+
return {
|
|
28
|
+
"events": TableDef(
|
|
29
|
+
name="events",
|
|
30
|
+
columns=[
|
|
31
|
+
ColumnDef("id", "int", {"pk"}),
|
|
32
|
+
ColumnDef("hinted_at", "timestamp", {"not null"}, note=hinted_note),
|
|
33
|
+
ColumnDef("plain_at", "timestamp", {"not null"}, note=plain_note),
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _column(df, name) -> list[datetime]:
|
|
40
|
+
return [datetime.fromisoformat(v) for v in df[name]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _business_hours_share(timestamps: list[datetime]) -> float:
|
|
44
|
+
in_hours = sum(1 for t in timestamps if t.weekday() < 5 and 8 <= t.hour < 18)
|
|
45
|
+
return in_hours / len(timestamps)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _timestamp_window_midpoint() -> date:
|
|
49
|
+
"""Midpoint of the 365-day timestamp window `weighted_timestamps` draws
|
|
50
|
+
from (see timeline.py's `_TIMESTAMP_WINDOW_DAYS`): the anchor minus a
|
|
51
|
+
year, up to the day before the anchor."""
|
|
52
|
+
start = ANCHOR_DATE - timedelta(days=365)
|
|
53
|
+
end = ANCHOR_DATE - timedelta(days=1)
|
|
54
|
+
return start + (end - start) / 2
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------
|
|
58
|
+
# A hint shapes its own column, not its neighbour
|
|
59
|
+
# ---------------------------------------------------------
|
|
60
|
+
def test_business_hours_hint_shapes_only_the_hinted_column():
|
|
61
|
+
tables = _two_timestamp_schema(hinted_note={"business_hours": True})
|
|
62
|
+
df = generate_data_from_dbml(
|
|
63
|
+
tables, [], base_rows=1000, seed=5, as_of=ANCHOR_DATE, time_profile=TimeProfile()
|
|
64
|
+
)["events"]
|
|
65
|
+
|
|
66
|
+
hinted_share = _business_hours_share(_column(df, "hinted_at"))
|
|
67
|
+
plain_share = _business_hours_share(_column(df, "plain_at"))
|
|
68
|
+
|
|
69
|
+
assert hinted_share >= 0.70
|
|
70
|
+
assert plain_share < hinted_share - 0.2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_growth_hint_shapes_only_the_hinted_column():
|
|
74
|
+
tables = _two_timestamp_schema(hinted_note={"growth": 1.0})
|
|
75
|
+
df = generate_data_from_dbml(
|
|
76
|
+
tables, [], base_rows=2000, seed=5, as_of=ANCHOR_DATE, time_profile=TimeProfile()
|
|
77
|
+
)["events"]
|
|
78
|
+
|
|
79
|
+
midpoint = _timestamp_window_midpoint()
|
|
80
|
+
hinted = _column(df, "hinted_at")
|
|
81
|
+
plain = _column(df, "plain_at")
|
|
82
|
+
|
|
83
|
+
hinted_second_half = sum(1 for t in hinted if t.date() >= midpoint) / len(hinted)
|
|
84
|
+
plain_second_half = sum(1 for t in plain if t.date() >= midpoint) / len(plain)
|
|
85
|
+
|
|
86
|
+
# growth=1.0 on hinted_at pushes its rows toward the second half of the
|
|
87
|
+
# window; plain_at, with no hint under a flat run-level profile, does not.
|
|
88
|
+
assert hinted_second_half > plain_second_half + 0.05
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------
|
|
92
|
+
# A hint overrides the run-level profile in both directions
|
|
93
|
+
# ---------------------------------------------------------
|
|
94
|
+
def test_hint_overrides_a_flat_run_level_profile():
|
|
95
|
+
tables = _two_timestamp_schema(hinted_note={"business_hours": True})
|
|
96
|
+
df = generate_data_from_dbml(
|
|
97
|
+
tables, [], base_rows=1000, seed=5, as_of=ANCHOR_DATE, time_profile=TimeProfile()
|
|
98
|
+
)["events"]
|
|
99
|
+
|
|
100
|
+
assert _business_hours_share(_column(df, "hinted_at")) >= 0.70
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_hint_overrides_a_shaped_run_level_profile_back_to_flat():
|
|
104
|
+
tables = _two_timestamp_schema(hinted_note={"business_hours": False})
|
|
105
|
+
df = generate_data_from_dbml(
|
|
106
|
+
tables,
|
|
107
|
+
[],
|
|
108
|
+
base_rows=1000,
|
|
109
|
+
seed=5,
|
|
110
|
+
as_of=ANCHOR_DATE,
|
|
111
|
+
time_profile=TimeProfile(business_hours=True),
|
|
112
|
+
)["events"]
|
|
113
|
+
|
|
114
|
+
# hinted_at is forced back to uniform; plain_at keeps the run's shaping.
|
|
115
|
+
hinted_share = _business_hours_share(_column(df, "hinted_at"))
|
|
116
|
+
plain_share = _business_hours_share(_column(df, "plain_at"))
|
|
117
|
+
assert plain_share >= 0.70
|
|
118
|
+
assert hinted_share < plain_share - 0.2
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_cli_flag_and_column_hint_combine_with_the_hint_winning():
|
|
122
|
+
"""`--business-hours` (a run-level TimeProfile) plus a column hint that
|
|
123
|
+
turns it back off for one column: the hint wins for that column, the
|
|
124
|
+
run-level setting still applies to its neighbour."""
|
|
125
|
+
tables = _two_timestamp_schema(hinted_note={"business_hours": False})
|
|
126
|
+
df = generate_data_from_dbml(
|
|
127
|
+
tables,
|
|
128
|
+
[],
|
|
129
|
+
base_rows=1000,
|
|
130
|
+
seed=5,
|
|
131
|
+
as_of=ANCHOR_DATE,
|
|
132
|
+
time_profile=TimeProfile(business_hours=True),
|
|
133
|
+
)["events"]
|
|
134
|
+
|
|
135
|
+
assert _business_hours_share(_column(df, "hinted_at")) < 0.5
|
|
136
|
+
assert _business_hours_share(_column(df, "plain_at")) >= 0.70
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------
|
|
140
|
+
# A partial hint only replaces the fields it names
|
|
141
|
+
# ---------------------------------------------------------
|
|
142
|
+
def test_partial_hint_keeps_the_other_run_level_fields():
|
|
143
|
+
"""`{"growth": 0}` flattens growth for this column but leaves
|
|
144
|
+
business_hours exactly as the run set it."""
|
|
145
|
+
tables = _two_timestamp_schema(hinted_note={"growth": 0.0})
|
|
146
|
+
run_profile = TimeProfile(business_hours=True, growth=1.0)
|
|
147
|
+
df = generate_data_from_dbml(
|
|
148
|
+
tables, [], base_rows=2000, seed=5, as_of=ANCHOR_DATE, time_profile=run_profile
|
|
149
|
+
)["events"]
|
|
150
|
+
|
|
151
|
+
hinted = _column(df, "hinted_at")
|
|
152
|
+
plain = _column(df, "plain_at")
|
|
153
|
+
|
|
154
|
+
# business_hours still applies to hinted_at (inherited, not overridden).
|
|
155
|
+
assert _business_hours_share(hinted) >= 0.70
|
|
156
|
+
|
|
157
|
+
# growth=0 on hinted_at flattens it relative to plain_at, which keeps the
|
|
158
|
+
# run's growth=1.0 and skews toward the second half of the window.
|
|
159
|
+
midpoint = _timestamp_window_midpoint()
|
|
160
|
+
hinted_second_half = sum(1 for t in hinted if t.date() >= midpoint) / len(hinted)
|
|
161
|
+
plain_second_half = sum(1 for t in plain if t.date() >= midpoint) / len(plain)
|
|
162
|
+
assert 0.35 <= hinted_second_half <= 0.65
|
|
163
|
+
assert plain_second_half > hinted_second_half
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------
|
|
167
|
+
# No hint: pinned frames unchanged
|
|
168
|
+
# ---------------------------------------------------------
|
|
169
|
+
def test_no_hint_reproduces_the_run_level_only_frame():
|
|
170
|
+
"""A column with no time hint must generate byte-identical values to the
|
|
171
|
+
same schema before this feature existed -- the override helper is a
|
|
172
|
+
pure no-op when a column's note carries none of the three keys."""
|
|
173
|
+
tables = _two_timestamp_schema()
|
|
174
|
+
with_helper = generate_data_from_dbml(
|
|
175
|
+
tables, [], base_rows=20, seed=2024, as_of=ANCHOR_DATE, time_profile=TimeProfile(growth=0.4)
|
|
176
|
+
)["events"]
|
|
177
|
+
again = generate_data_from_dbml(
|
|
178
|
+
tables, [], base_rows=20, seed=2024, as_of=ANCHOR_DATE, time_profile=TimeProfile(growth=0.4)
|
|
179
|
+
)["events"]
|
|
180
|
+
|
|
181
|
+
assert with_helper.equals(again)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_no_hint_and_uniform_profile_matches_the_pre_feature_pinned_frame():
|
|
185
|
+
"""Same schema and seed as test_timeline.py's own pinned-frame test, with
|
|
186
|
+
an explicit empty note on both columns: still byte-identical to 1.5.0."""
|
|
187
|
+
tables = {
|
|
188
|
+
"events": TableDef(
|
|
189
|
+
name="events",
|
|
190
|
+
columns=[
|
|
191
|
+
ColumnDef("id", "int", {"pk"}),
|
|
192
|
+
ColumnDef("created_at", "timestamp", {"not null"}),
|
|
193
|
+
ColumnDef("event_date", "date", set()),
|
|
194
|
+
],
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
df = generate_data_from_dbml(tables, [], base_rows=8, seed=2024, as_of=ANCHOR_DATE)["events"]
|
|
198
|
+
|
|
199
|
+
assert list(df["created_at"])[:2] == ["2026-01-24 23:52:50", "2025-08-07 01:00:18"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------
|
|
203
|
+
# Validation
|
|
204
|
+
# ---------------------------------------------------------
|
|
205
|
+
def _single_column_table(column: ColumnDef, table_name: str = "t") -> dict[str, TableDef]:
|
|
206
|
+
return {table_name: TableDef(name=table_name, columns=[ColumnDef("id", "int", {"pk"}), column])}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.parametrize(
|
|
210
|
+
"key, value", [("business_hours", True), ("growth", 0.5), ("seasonality", 0.5)]
|
|
211
|
+
)
|
|
212
|
+
def test_hint_on_a_non_temporal_column_is_rejected(key, value):
|
|
213
|
+
tables = _single_column_table(ColumnDef("label", "varchar", note={key: value}))
|
|
214
|
+
with pytest.raises(ValueError, match=r"t\.label"):
|
|
215
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_growth_below_negative_one_is_rejected():
|
|
219
|
+
tables = _single_column_table(ColumnDef("happened_at", "timestamp", note={"growth": -1.5}))
|
|
220
|
+
with pytest.raises(ValueError, match=r"t\.happened_at.*growth"):
|
|
221
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_growth_as_a_bool_is_rejected():
|
|
225
|
+
tables = _single_column_table(ColumnDef("happened_at", "timestamp", note={"growth": True}))
|
|
226
|
+
with pytest.raises(ValueError, match=r"t\.happened_at"):
|
|
227
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_seasonality_out_of_range_is_rejected():
|
|
231
|
+
tables = _single_column_table(ColumnDef("happened_at", "timestamp", note={"seasonality": 1.5}))
|
|
232
|
+
with pytest.raises(ValueError, match=r"t\.happened_at"):
|
|
233
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_business_hours_as_a_non_bool_is_rejected():
|
|
237
|
+
tables = _single_column_table(
|
|
238
|
+
ColumnDef("happened_at", "timestamp", note={"business_hours": "yes"})
|
|
239
|
+
)
|
|
240
|
+
with pytest.raises(ValueError, match=r"t\.happened_at"):
|
|
241
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Numeric distribution hints: `distribution` and its `mean`/`stddev`/`median`/`spread`
|
|
2
|
+
parameters on integer and decimal columns.
|
|
3
|
+
|
|
4
|
+
The product ask this closes was "can I make a normal distribution or another kind
|
|
5
|
+
of distribution as well?" on a numeric column -- `min`/`max` alone could only shape
|
|
6
|
+
a uniform spread. Every statistical test here uses a fixed seed and a generous
|
|
7
|
+
tolerance so it never flakes; the exact bounds were chosen by running the real
|
|
8
|
+
implementation and leaving headroom, not derived analytically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import statistics
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from model2data.generate.core import generate_data_from_dbml
|
|
16
|
+
from model2data.parse.dbml import ColumnDef, TableDef
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _single_column_table(column: ColumnDef, table_name: str = "t") -> dict[str, TableDef]:
|
|
20
|
+
return {table_name: TableDef(name=table_name, columns=[ColumnDef("id", "int", {"pk"}), column])}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------
|
|
24
|
+
# Step 0: pin today's (uniform) behaviour before touching it
|
|
25
|
+
# ---------------------------------------------------------
|
|
26
|
+
def test_no_distribution_hint_reproduces_pre_1_7_frames():
|
|
27
|
+
"""A numeric column with no `distribution` key must still produce exactly
|
|
28
|
+
what 1.6.0 produced with the same seed -- the guard rail every distribution
|
|
29
|
+
added here works against."""
|
|
30
|
+
tables = {
|
|
31
|
+
"t": TableDef(
|
|
32
|
+
name="t",
|
|
33
|
+
columns=[
|
|
34
|
+
ColumnDef("id", "int", {"pk"}),
|
|
35
|
+
ColumnDef("amount", "numeric", {"not null"}),
|
|
36
|
+
ColumnDef("qty", "int", {"not null"}),
|
|
37
|
+
],
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
data = generate_data_from_dbml(tables, [], base_rows=8, seed=777)
|
|
41
|
+
assert data["t"]["amount"].tolist() == [
|
|
42
|
+
4169.09,
|
|
43
|
+
3848.52,
|
|
44
|
+
5163.13,
|
|
45
|
+
3006.66,
|
|
46
|
+
25.14,
|
|
47
|
+
4363.61,
|
|
48
|
+
685.34,
|
|
49
|
+
8357.29,
|
|
50
|
+
]
|
|
51
|
+
assert data["t"]["qty"].tolist() == [7, 89, 51, 52, 82, 100, 97, 9]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------
|
|
55
|
+
# Shape: each distribution draws roughly what it says it does
|
|
56
|
+
# ---------------------------------------------------------
|
|
57
|
+
def test_normal_distribution_matches_mean_and_stddev():
|
|
58
|
+
tables = _single_column_table(
|
|
59
|
+
ColumnDef(
|
|
60
|
+
"amount",
|
|
61
|
+
"numeric",
|
|
62
|
+
{"not null"},
|
|
63
|
+
note={"distribution": "normal", "mean": 100, "stddev": 10},
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
data = generate_data_from_dbml(tables, [], base_rows=5000, seed=1)
|
|
67
|
+
values = data["t"]["amount"].tolist()
|
|
68
|
+
assert 97 <= statistics.mean(values) <= 103
|
|
69
|
+
assert 8 <= statistics.stdev(values) <= 12
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_lognormal_distribution_median_and_mean():
|
|
73
|
+
tables = _single_column_table(
|
|
74
|
+
ColumnDef(
|
|
75
|
+
"amount",
|
|
76
|
+
"numeric",
|
|
77
|
+
{"not null"},
|
|
78
|
+
note={"distribution": "lognormal", "median": 80, "spread": 0.5},
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
data = generate_data_from_dbml(tables, [], base_rows=5000, seed=2)
|
|
82
|
+
values = data["t"]["amount"].tolist()
|
|
83
|
+
sample_median = statistics.median(values)
|
|
84
|
+
assert 70 <= sample_median <= 90
|
|
85
|
+
assert statistics.mean(values) > sample_median
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_exponential_distribution_matches_mean():
|
|
89
|
+
tables = _single_column_table(
|
|
90
|
+
ColumnDef(
|
|
91
|
+
"amount", "numeric", {"not null"}, note={"distribution": "exponential", "mean": 30}
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
data = generate_data_from_dbml(tables, [], base_rows=5000, seed=3)
|
|
95
|
+
values = data["t"]["amount"].tolist()
|
|
96
|
+
assert 26 <= statistics.mean(values) <= 34
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_missing_parameters_default_to_midpoint_and_derived_spread():
|
|
100
|
+
"""No `mean`/`stddev` at all: normal centres on the midpoint of the
|
|
101
|
+
default int range (0-100 -> 50) with stddev (max-min)/6."""
|
|
102
|
+
tables = _single_column_table(
|
|
103
|
+
ColumnDef("qty", "int", {"not null"}, note={"distribution": "normal"})
|
|
104
|
+
)
|
|
105
|
+
data = generate_data_from_dbml(tables, [], base_rows=5000, seed=7)
|
|
106
|
+
values = data["t"]["qty"].tolist()
|
|
107
|
+
assert 45 <= statistics.mean(values) <= 55
|
|
108
|
+
assert 12 <= statistics.stdev(values) <= 21
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_missing_lognormal_parameters_default_to_midpoint_and_half_spread():
|
|
112
|
+
"""No `median`/`spread`: lognormal centres on the midpoint of the default
|
|
113
|
+
decimal range (0-10,000 -> 5,000)."""
|
|
114
|
+
tables = _single_column_table(
|
|
115
|
+
ColumnDef("amount", "numeric", {"not null"}, note={"distribution": "lognormal"})
|
|
116
|
+
)
|
|
117
|
+
data = generate_data_from_dbml(tables, [], base_rows=5000, seed=8)
|
|
118
|
+
values = data["t"]["amount"].tolist()
|
|
119
|
+
assert 4000 <= statistics.median(values) <= 6000
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_missing_exponential_mean_defaults_to_midpoint():
|
|
123
|
+
"""No `mean`: exponential's scale defaults to the midpoint of the default
|
|
124
|
+
int range (0-100 -> 50)."""
|
|
125
|
+
tables = _single_column_table(
|
|
126
|
+
ColumnDef("qty", "int", {"not null"}, note={"distribution": "exponential"})
|
|
127
|
+
)
|
|
128
|
+
data = generate_data_from_dbml(tables, [], base_rows=5000, seed=9)
|
|
129
|
+
values = data["t"]["qty"].tolist()
|
|
130
|
+
assert 40 <= statistics.mean(values) <= 60
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------
|
|
134
|
+
# Clipping and typing
|
|
135
|
+
# ---------------------------------------------------------
|
|
136
|
+
def test_clipping_with_explicit_min_leaves_no_negatives():
|
|
137
|
+
"""A normal whose mean sits close to an explicit `min` would otherwise
|
|
138
|
+
draw negatives; redraw-then-clamp keeps every value in bounds."""
|
|
139
|
+
tables = _single_column_table(
|
|
140
|
+
ColumnDef(
|
|
141
|
+
"amount",
|
|
142
|
+
"numeric",
|
|
143
|
+
{"not null"},
|
|
144
|
+
note={"distribution": "normal", "mean": 5, "stddev": 10, "min": 0},
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
data = generate_data_from_dbml(tables, [], base_rows=2000, seed=4)
|
|
148
|
+
values = data["t"]["amount"].tolist()
|
|
149
|
+
assert min(values) >= 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_integer_columns_stay_ints_under_a_distribution():
|
|
153
|
+
tables = _single_column_table(
|
|
154
|
+
ColumnDef(
|
|
155
|
+
"qty", "int", {"not null"}, note={"distribution": "normal", "mean": 50, "stddev": 10}
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
data = generate_data_from_dbml(tables, [], base_rows=200, seed=5)
|
|
159
|
+
values = data["t"]["qty"].tolist()
|
|
160
|
+
assert all(isinstance(v, int) for v in values)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_unique_normal_column_stays_unique():
|
|
164
|
+
"""`ensure_unique` still resolves collisions when the generator draws
|
|
165
|
+
from a distribution instead of `random.sample` -- given a spread wide
|
|
166
|
+
enough to hold that many distinct values."""
|
|
167
|
+
tables = {
|
|
168
|
+
"t": TableDef(
|
|
169
|
+
name="t",
|
|
170
|
+
columns=[
|
|
171
|
+
ColumnDef(
|
|
172
|
+
"id",
|
|
173
|
+
"int",
|
|
174
|
+
{"pk"},
|
|
175
|
+
note={"distribution": "normal", "mean": 5000, "stddev": 500},
|
|
176
|
+
)
|
|
177
|
+
],
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
data = generate_data_from_dbml(tables, [], base_rows=200, seed=6)
|
|
181
|
+
values = data["t"]["id"].tolist()
|
|
182
|
+
assert len(set(values)) == len(values)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------
|
|
186
|
+
# Validation: every error names the column
|
|
187
|
+
# ---------------------------------------------------------
|
|
188
|
+
def test_distribution_on_a_non_numeric_column_is_rejected():
|
|
189
|
+
tables = _single_column_table(ColumnDef("label", "varchar", note={"distribution": "normal"}))
|
|
190
|
+
with pytest.raises(ValueError, match=r"t\.label"):
|
|
191
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_distribution_rejects_an_unknown_value():
|
|
195
|
+
tables = _single_column_table(ColumnDef("total", "numeric", note={"distribution": "gaussian"}))
|
|
196
|
+
with pytest.raises(ValueError, match=r"t\.total"):
|
|
197
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_stddev_without_distribution_is_rejected():
|
|
201
|
+
tables = _single_column_table(ColumnDef("total", "numeric", note={"stddev": 5}))
|
|
202
|
+
with pytest.raises(
|
|
203
|
+
ValueError, match=r't\.total: "stddev" only applies with "distribution": "normal"\.'
|
|
204
|
+
):
|
|
205
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_stddev_with_the_wrong_distribution_is_rejected():
|
|
209
|
+
tables = _single_column_table(
|
|
210
|
+
ColumnDef("total", "numeric", note={"distribution": "exponential", "stddev": 5})
|
|
211
|
+
)
|
|
212
|
+
with pytest.raises(ValueError, match=r"t\.total"):
|
|
213
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_median_with_the_wrong_distribution_is_rejected():
|
|
217
|
+
tables = _single_column_table(
|
|
218
|
+
ColumnDef("total", "numeric", note={"distribution": "normal", "median": 5})
|
|
219
|
+
)
|
|
220
|
+
with pytest.raises(ValueError, match=r"t\.total"):
|
|
221
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_spread_with_the_wrong_distribution_is_rejected():
|
|
225
|
+
tables = _single_column_table(
|
|
226
|
+
ColumnDef("total", "numeric", note={"distribution": "normal", "spread": 0.5})
|
|
227
|
+
)
|
|
228
|
+
with pytest.raises(ValueError, match=r"t\.total"):
|
|
229
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_mean_without_a_matching_distribution_is_rejected():
|
|
233
|
+
tables = _single_column_table(ColumnDef("total", "numeric", note={"mean": 5}))
|
|
234
|
+
with pytest.raises(ValueError, match=r"t\.total"):
|
|
235
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@pytest.mark.parametrize(
|
|
239
|
+
"note",
|
|
240
|
+
[
|
|
241
|
+
{"distribution": "normal", "stddev": -1},
|
|
242
|
+
{"distribution": "normal", "stddev": 0},
|
|
243
|
+
{"distribution": "lognormal", "median": -5},
|
|
244
|
+
{"distribution": "lognormal", "spread": 0},
|
|
245
|
+
],
|
|
246
|
+
)
|
|
247
|
+
def test_non_positive_stddev_median_spread_is_rejected(note):
|
|
248
|
+
tables = _single_column_table(ColumnDef("total", "numeric", note=note))
|
|
249
|
+
with pytest.raises(ValueError, match=r"t\.total"):
|
|
250
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_a_bool_value_is_rejected_where_a_number_is_expected():
|
|
254
|
+
tables = _single_column_table(
|
|
255
|
+
ColumnDef("total", "numeric", note={"distribution": "normal", "mean": True})
|
|
256
|
+
)
|
|
257
|
+
with pytest.raises(ValueError, match=r"t\.total"):
|
|
258
|
+
generate_data_from_dbml(tables, [], base_rows=5, seed=1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{model2data-1.5.0 → model2data-1.7.0}/model2data/dbt/templates/macros/generate_schema_name.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|