File Coverage

blib/lib/Flat/Schema.pm
Criterion Covered Total %
statement 348 424 82.0
branch 148 236 62.7
condition 70 166 42.1
subroutine 28 28 100.0
pod 0 4 0.0
total 594 858 69.2


line stmt bran cond sub pod time code
1             package Flat::Schema;
2              
3 6     6   1005408 use strict;
  6         17  
  6         236  
4 6     6   60 use warnings;
  6         12  
  6         442  
5              
6 6     6   39 use Carp qw(croak);
  6         11  
  6         41619  
7              
8             our $VERSION = '0.01';
9              
10             sub new {
11 9     9 0 30 my ($class, %options) = @_;
12              
13 9         31 my $self = {
14             options => { %options },
15             };
16              
17 9         31 return bless $self, $class;
18             }
19              
20             sub from_profile {
21 8     8 0 1095644 my ($class, %args) = @_;
22              
23 8 50       42 if (!exists $args{profile}) {
24 0         0 croak "from_profile(): missing required named argument: profile";
25             }
26              
27 8         15 my $profile = $args{profile};
28 8 50       55 if (ref($profile) ne 'HASH') {
29 0         0 croak "from_profile(): profile must be a hash reference";
30             }
31              
32 8         17 my $report_version = $profile->{report_version};
33 8 50 33     82 if (!defined $report_version || $report_version !~ /\A\d+\z/) {
34 0         0 croak "from_profile(): profile.report_version must be an integer";
35             }
36 8 50       25 if ($report_version < 1) {
37 0         0 croak "from_profile(): unsupported profile.report_version ($report_version); must be >= 1";
38             }
39              
40 8         16 my $profile_columns = $profile->{columns};
41 8 50       30 if (ref($profile_columns) ne 'ARRAY') {
42 0         0 croak "from_profile(): profile.columns must be an array reference";
43             }
44              
45 8         15 my $overrides = undef;
46 8 100       42 if (exists $args{overrides}) {
47 1         4 $overrides = $args{overrides};
48 1 50 33     8 if (defined $overrides && ref($overrides) ne 'ARRAY') {
49 0         0 croak "from_profile(): overrides must be an array reference";
50             }
51             }
52              
53 8         87 my $self = $class->new();
54 8         50 return $self->_build_schema_from_profile($profile, $overrides);
55             }
56              
57             sub _build_schema_from_profile {
58 8     8   18 my ($self, $profile, $overrides_in) = @_;
59              
60 8         16 my $issues = [];
61              
62 8         33 my $columns = $self->_columns_from_profile($profile, $issues);
63              
64 8         43 _apply_nullability_and_null_issues($profile, $columns, $issues);
65              
66 8         27 my $overrides_map = _normalize_overrides($overrides_in);
67 8         31 _apply_overrides($columns, $overrides_map, $issues);
68              
69 8         88 my $schema = {
70             schema_version => 1,
71             generator => {
72             name => 'Flat::Schema',
73             version => $VERSION,
74             },
75             profile => $self->_profile_meta_from_profile($profile),
76             columns => $columns,
77             issues => [],
78             };
79              
80 8         28 $schema->{issues} = _sort_issues_deterministically($issues);
81              
82 8         81 return $schema;
83             }
84              
85             sub _profile_meta_from_profile {
86 8     8   20 my ($self, $profile) = @_;
87              
88             my %meta = (
89 8         27 report_version => int($profile->{report_version}),
90             );
91              
92 8 100       39 if (exists $profile->{null_empty}) {
93 2 50       5 $meta{null_empty} = $profile->{null_empty} ? 1 : 0;
94             }
95 8 100 66     31 if (exists $profile->{null_tokens} && ref($profile->{null_tokens}) eq 'ARRAY') {
96 2         3 $meta{null_tokens} = [ @{ $profile->{null_tokens} } ];
  2         6  
97             }
98              
99 8 50 66     69 if (exists $profile->{rows_profiled} && defined $profile->{rows_profiled} && $profile->{rows_profiled} =~ /\A\d+\z/) {
      66        
100 4         10 $meta{rows_profiled} = int($profile->{rows_profiled});
101             }
102              
103 8         62 return \%meta;
104             }
105              
106             sub _columns_from_profile {
107 8     8   22 my ($self, $profile, $issues) = @_;
108              
109 8         14 my @columns_in = @{ $profile->{columns} };
  8         29  
110              
111             @columns_in = sort {
112 8   50     41 ($a->{index} // 0) <=> ($b->{index} // 0)
  10   50     59  
113             } @columns_in;
114              
115 8         16 my @columns_out;
116              
117 8         20 for my $col (@columns_in) {
118 17 50       52 if (ref($col) ne 'HASH') {
119 0         0 croak "from_profile(): each element of profile.columns must be a hash reference";
120             }
121              
122 17 50 33     167 if (!exists $col->{index} || !defined $col->{index} || $col->{index} !~ /\A\d+\z/) {
      33        
123 0         0 croak "from_profile(): each column must have an integer index";
124             }
125              
126 17         35 my $index = int($col->{index});
127              
128 17 50       51 my $name = exists $col->{name} ? $col->{name} : undef;
129 17 50 33     68 if (defined $name && ref($name) ne '') {
130 0         0 croak "from_profile(): column.name must be a string or undef";
131             }
132              
133 17         56 my ($type, $type_issues) = _infer_type_from_column($col);
134              
135 17         47 for my $issue (@$type_issues) {
136 3         7 $issue->{column_index} = $index;
137 3         7 push @$issues, $issue;
138             }
139              
140 17         61 my $rows_observed = 0;
141 17 50 66     138 if (exists $col->{rows_observed} && defined $col->{rows_observed} && $col->{rows_observed} =~ /\A\d+\z/) {
      66        
142 13         68 $rows_observed = int($col->{rows_observed});
143             }
144              
145 17         29 my $null_count = 0;
146 17 50 66     117 if (exists $col->{null_count} && defined $col->{null_count} && $col->{null_count} =~ /\A\d+\z/) {
      66        
147 13         43 $null_count = int($col->{null_count});
148             }
149              
150 17         174 my $out = {
151             index => $index,
152             name => $name,
153             type => $type,
154             nullable => 1,
155             provenance => {
156             basis => 'profile',
157             rows_observed => $rows_observed,
158             null_count => $null_count,
159             null_rate => {
160             num => $null_count,
161             den => $rows_observed,
162             },
163             },
164             };
165              
166 17         58 push @columns_out, $out;
167             }
168              
169 8         27 return \@columns_out;
170             }
171              
172             # ------------------------
173             # Nullability inference (v1)
174             # ------------------------
175              
176             sub _apply_nullability_and_null_issues {
177 8     8   22 my ($profile, $columns, $issues) = @_;
178              
179 8         13 my $any_rows_observed = 0;
180              
181 8         21 for my $col (@$columns) {
182 17   50     64 my $rows_observed = int($col->{provenance}{rows_observed} // 0);
183 17   50     64 my $null_count = int($col->{provenance}{null_count} // 0);
184              
185 17 100       40 if ($rows_observed > 0) {
186 11         26 $any_rows_observed = 1;
187             }
188              
189 17 100       46 if ($rows_observed == 0) {
190 6         11 $col->{nullable} = 1;
191 6         14 next;
192             }
193              
194 11 100       27 $col->{nullable} = $null_count > 0 ? 1 : 0;
195              
196 11 100       27 if ($null_count == $rows_observed) {
197             push @$issues, {
198             level => 'warning',
199             code => 'all_null_column',
200             message => 'Column contains only null values in profiled rows',
201             column_index => $col->{index},
202 1         9 details => {
203             null_count => $null_count,
204             rows_observed => $rows_observed,
205             },
206             };
207             }
208             }
209              
210 8         16 my $rows_profiled = undef;
211 8 50 66     75 if (exists $profile->{rows_profiled} && defined $profile->{rows_profiled} && $profile->{rows_profiled} =~ /\A\d+\z/) {
      66        
212 4         10 $rows_profiled = int($profile->{rows_profiled});
213             }
214              
215 8         12 my $no_rows = 0;
216 8 100       22 if (defined $rows_profiled) {
217 4 100       21 $no_rows = $rows_profiled == 0 ? 1 : 0;
218             } else {
219 4 100       11 $no_rows = $any_rows_observed ? 0 : 1;
220             }
221              
222 8 100       46 if ($no_rows) {
223 2         25 push @$issues, {
224             level => 'warning',
225             code => 'no_rows_profiled',
226             message => 'Profile report indicates zero rows were profiled; schema inference is limited',
227             column_index => undef,
228             };
229             }
230              
231 8         18 return;
232             }
233              
234             # ------------------------
235             # Overrides (v1)
236             # ------------------------
237              
238             sub _normalize_overrides {
239 8     8   16 my ($overrides_in) = @_;
240              
241 8 100       22 if (!defined $overrides_in) {
242 7         25 return {};
243             }
244              
245 1         3 my %map;
246              
247 1         2 for my $entry (@$overrides_in) {
248 2 50       10 if (ref($entry) ne 'HASH') {
249 0         0 croak "from_profile(): each overrides entry must be a hash reference";
250             }
251              
252 2 50 33     23 if (!exists $entry->{column_index} || !defined $entry->{column_index} || $entry->{column_index} !~ /\A\d+\z/) {
      33        
253 0         0 croak "from_profile(): overrides entry missing integer column_index";
254             }
255              
256 2         6 my $idx = int($entry->{column_index});
257              
258 2 50 33     18 if (!exists $entry->{set} || ref($entry->{set}) ne 'HASH') {
259 0         0 croak "from_profile(): overrides entry missing set hash";
260             }
261              
262 2         5 my %set = %{ $entry->{set} };
  2         10  
263              
264 2         5 my %allowed = map { $_ => 1 } qw(type nullable name length);
  8         26  
265 2         6 for my $k (keys %set) {
266 5 50       12 if (!$allowed{$k}) {
267 0         0 croak "from_profile(): override field not supported in v1: $k";
268             }
269             }
270              
271 2 100       6 if (exists $set{type}) {
272 1 50 33     47 if (!defined $set{type} || ref($set{type}) ne '') {
273 0         0 croak "from_profile(): override type must be a string";
274             }
275             }
276              
277 2 50       5 if (exists $set{nullable}) {
278 2 50 33     10 if (!defined $set{nullable} || ref($set{nullable}) ne '') {
279 0         0 croak "from_profile(): override nullable must be a scalar boolean (0/1)";
280             }
281 2 100       13 $set{nullable} = $set{nullable} ? 1 : 0;
282             }
283              
284 2 100       5 if (exists $set{name}) {
285 1 50 33     5 if (defined $set{name} && ref($set{name}) ne '') {
286 0         0 croak "from_profile(): override name must be a string or undef";
287             }
288             }
289              
290 2 100       6 if (exists $set{length}) {
291 1 50 33     7 if (!defined $set{length} || ref($set{length}) ne 'HASH') {
292 0         0 croak "from_profile(): override length must be a hash reference";
293             }
294              
295 1         7 my %len = %{ $set{length} };
  1         5  
296 1         4 my %len_allowed = map { $_ => 1 } qw(min max);
  2         7  
297              
298 1         4 for my $lk (keys %len) {
299 2 50       5 if (!$len_allowed{$lk}) {
300 0         0 croak "from_profile(): override length supports only min/max";
301             }
302 2 50 33     17 if (defined $len{$lk} && $len{$lk} !~ /\A\d+\z/) {
303 0         0 croak "from_profile(): override length.$lk must be an integer";
304             }
305 2 50       7 $len{$lk} = int($len{$lk}) if defined $len{$lk};
306             }
307              
308 1         5 $set{length} = \%len;
309             }
310              
311 2 50       7 $map{$idx} = {} if !exists $map{$idx};
312 2         8 for my $k (sort keys %set) {
313 5         14 $map{$idx}{$k} = $set{$k};
314             }
315             }
316              
317 1         84 return \%map;
318             }
319              
320             sub _apply_overrides {
321 8     8   20 my ($columns, $overrides_map, $issues) = @_;
322              
323 8 100       31 return if !%$overrides_map;
324              
325 1         2 my %col_by_index = map { $_->{index} => $_ } @$columns;
  2         9  
326              
327 1         7 for my $idx (sort { $a <=> $b } keys %$overrides_map) {
  1         5  
328 2 50       7 if (!exists $col_by_index{$idx}) {
329 0         0 croak "from_profile(): override references unknown column_index $idx";
330             }
331              
332 2         3 my $col = $col_by_index{$idx};
333 2         4 my $set = $overrides_map->{$idx};
334              
335 2         4 my @fields_applied;
336              
337 2         21 for my $field (sort keys %$set) {
338 5         13 my $override_value = $set->{$field};
339              
340 5 100       12 if ($field eq 'length') {
341 1 50       4 my $inferred = exists $col->{length} ? $col->{length} : undef;
342 1         5 my $different = _different_length($inferred, $override_value);
343              
344 1 50       3 if ($different) {
345             push @$issues, {
346             level => 'warning',
347             code => 'override_conflicts_with_profile',
348             message => 'Override conflicts with inferred value',
349             column_index => $col->{index},
350 1         5 details => {
351             field => 'length',
352             overridden_value => _stable_details_string($override_value),
353             inferred_value => _stable_details_string($inferred),
354             },
355             };
356             }
357              
358 1         2 $col->{length} = { %{ $override_value } };
  1         5  
359 1         5 _record_override($col, 'length', $override_value);
360              
361 1         2 push @fields_applied, 'length';
362 1         3 next;
363             }
364              
365             _override_scalar_field(
366 4         20 col => $col,
367             field => $field,
368             override_value => $override_value,
369             issues => $issues,
370             );
371              
372 4         11 push @fields_applied, $field;
373             }
374              
375 2 50       5 if (@fields_applied) {
376 2         9 my @sorted_fields = sort @fields_applied;
377              
378             push @$issues, {
379             level => 'info',
380             code => 'override_applied',
381             message => 'Overrides applied to column',
382             column_index => $col->{index},
383 2         46 details => {
384             fields => \@sorted_fields,
385             },
386             };
387              
388 2         10 $col->{provenance}{overrides} = [ @sorted_fields ];
389             }
390             }
391              
392 1         3 return;
393             }
394              
395             sub _different_length {
396 1     1   3 my ($a, $b) = @_;
397              
398 1 50 33     12 if (!defined $a && !defined $b) {
399 0         0 return 0;
400             }
401 1 50 33     4 if (!defined $a || !defined $b) {
402 1         4 return 1;
403             }
404 0 0 0     0 if (ref($a) ne 'HASH' || ref($b) ne 'HASH') {
405 0         0 return 1;
406             }
407              
408 0         0 for my $k (qw(min max)) {
409 0 0       0 my $av = exists $a->{$k} ? $a->{$k} : undef;
410 0 0       0 my $bv = exists $b->{$k} ? $b->{$k} : undef;
411              
412 0 0       0 if ((defined $av) != (defined $bv)) {
413 0         0 return 1;
414             }
415 0 0 0     0 if (defined $av && defined $bv && int($av) != int($bv)) {
      0        
416 0         0 return 1;
417             }
418             }
419              
420 0         0 return 0;
421             }
422              
423             sub _override_scalar_field {
424 4     4   15 my (%args) = @_;
425              
426 4         7 my $col = $args{col};
427 4         9 my $field = $args{field};
428 4         7 my $override_value = $args{override_value};
429 4         6 my $issues = $args{issues};
430              
431 4 100       11 if ($field eq 'nullable') {
432 2 100       5 $override_value = $override_value ? 1 : 0;
433             }
434              
435 4 50       12 my $inferred_value = exists $col->{$field} ? $col->{$field} : undef;
436              
437 4         8 my $different = 0;
438 4 50 33     23 if (!defined $inferred_value && !defined $override_value) {
    50 33        
439 0         0 $different = 0;
440             } elsif (!defined $inferred_value || !defined $override_value) {
441 0         0 $different = 1;
442             } else {
443 4 50       12 $different = ($inferred_value ne $override_value) ? 1 : 0;
444             }
445              
446 4 50       21 if ($different) {
447             push @$issues, {
448             level => 'warning',
449             code => 'override_conflicts_with_profile',
450             message => 'Override conflicts with inferred value',
451             column_index => $col->{index},
452 4 50       30 details => {
    50          
453             field => $field,
454             overridden_value => defined $override_value ? $override_value : undef,
455             inferred_value => defined $inferred_value ? $inferred_value : undef,
456             },
457             };
458             }
459              
460 4         11 $col->{$field} = $override_value;
461 4         11 _record_override($col, $field, $override_value);
462              
463 4         10 return;
464             }
465              
466             sub _record_override {
467 5     5   10 my ($col, $field, $value) = @_;
468              
469 5 100 66     21 $col->{overrides} = {} if !exists $col->{overrides} || ref($col->{overrides}) ne 'HASH';
470              
471 5 100       28 if ($field eq 'length') {
472 1         3 $col->{overrides}{length} = { %{ $value } };
  1         4  
473 1         3 return;
474             }
475              
476 4         11 $col->{overrides}{$field} = $value;
477              
478 4         7 return;
479             }
480              
481             # ------------------------
482             # Type inference (v1)
483             # ------------------------
484              
485             sub _infer_type_from_column {
486 17     17   39 my ($col) = @_;
487              
488 17         31 my $evidence = $col->{type_evidence};
489              
490 17 50 66     81 if (!defined $evidence || ref($evidence) ne 'HASH' || !%$evidence) {
      66        
491 11         37 return ('string', []);
492             }
493              
494             my %counts = map {
495 6   100     15 $_ => int($evidence->{$_} // 0)
  36         223  
496             } qw(string integer number boolean date datetime);
497              
498 6         26 my @present = sort grep { $counts{$_} > 0 } keys %counts;
  36         106  
499              
500 6 50       21 if (!@present) {
501 0         0 return ('string', []);
502             }
503              
504 6 100       13 my @temporal = grep { $_ eq 'date' || $_ eq 'datetime' } @present;
  9         40  
505 6 100       12 my @other = grep { $_ ne 'date' && $_ ne 'datetime' } @present;
  9         45  
506              
507 6 100 100     34 if (@temporal && @other) {
508             return (
509 1         12 'string',
510             [
511             {
512             level => 'warning',
513             code => 'temporal_conflict_widened_to_string',
514             message => 'Temporal and non-temporal values mixed; widened to string',
515             details => {
516             temporal_candidates => \@temporal,
517             other_candidates => \@other,
518             chosen => 'string',
519             },
520             },
521             ],
522             );
523             }
524              
525 5 100       13 if (@temporal) {
526 2 100       5 if (grep { $_ eq 'datetime' } @temporal) {
  3         10  
527 1 50       4 if (@temporal > 1) {
528             return (
529 1         24 'datetime',
530             [
531             {
532             level => 'info',
533             code => 'type_widened',
534             message => 'Date values widened to datetime',
535             details => {
536             from => 'date',
537             to => 'datetime',
538             },
539             },
540             ],
541             );
542             }
543 0         0 return ('datetime', []);
544             }
545 1         7 return ('date', []);
546             }
547              
548 3         10 my @order = qw(boolean integer number string);
549 3         10 my %rank = map { $order[$_] => $_ } 0 .. $#order;
  12         32  
550              
551 3         13 my $chosen = (sort { $rank{$a} <=> $rank{$b} } @present)[-1];
  1         5  
552              
553 3 100       10 if (@present > 1) {
554             return (
555 1         17 $chosen,
556             [
557             {
558             level => 'warning',
559             code => 'mixed_type_evidence',
560             message => 'Multiple scalar types observed; widened',
561             details => {
562             candidates => \@present,
563             chosen => $chosen,
564             },
565             },
566             ],
567             );
568             }
569              
570 2         13 return ($chosen, []);
571             }
572              
573             # ------------------------
574             # Issues ordering (deterministic)
575             # ------------------------
576              
577             sub _sort_issues_deterministically {
578 8     8   17 my ($issues) = @_;
579              
580 8         33 my %level_rank = (
581             info => 0,
582             warning => 1,
583             );
584              
585             my @sorted = sort {
586 8 50       24 my $la = exists $a->{level} ? $a->{level} : 'warning';
  17         43  
587 17 50       38 my $lb = exists $b->{level} ? $b->{level} : 'warning';
588              
589 17 50       40 my $ra = exists $level_rank{$la} ? $level_rank{$la} : 9;
590 17 50       42 my $rb = exists $level_rank{$lb} ? $level_rank{$lb} : 9;
591              
592             return $ra <=> $rb
593             || ($a->{code} // '') cmp ($b->{code} // '')
594             || _cmp_column_index($a->{column_index}, $b->{column_index})
595             || ($a->{message} // '') cmp ($b->{message} // '')
596 17   66     129 || _stable_details_string($a->{details}) cmp _stable_details_string($b->{details});
597             } @$issues;
598              
599 8         38 return \@sorted;
600             }
601              
602             sub _cmp_column_index {
603 9     9   20 my ($a, $b) = @_;
604              
605 9         12 my $a_is_undef = !defined $a;
606 9         14 my $b_is_undef = !defined $b;
607              
608 9 50 33     20 if ($a_is_undef && $b_is_undef) {
609 0         0 return 0;
610             }
611 9 50       18 if ($a_is_undef) {
612 0         0 return 1;
613             }
614 9 50       15 if ($b_is_undef) {
615 0         0 return -1;
616             }
617              
618 9         49 return int($a) <=> int($b);
619             }
620              
621             sub _stable_details_string {
622 10     10   21 my ($details) = @_;
623              
624 10 100       20 if (!defined $details) {
625 1         18 return '';
626             }
627              
628 9 50       22 if (ref($details) eq 'HASH') {
629 9         38 my @keys = sort keys %$details;
630 9         15 my @pairs;
631              
632 9         19 for my $k (@keys) {
633 26         42 my $v = $details->{$k};
634 26 50       70 if (!defined $v) {
    50          
    50          
635 0         0 push @pairs, $k . '=';
636             } elsif (ref($v) eq 'ARRAY') {
637 0         0 push @pairs, $k . '=[' . join(',', @$v) . ']';
638             } elsif (ref($v) eq 'HASH') {
639 0         0 my @ik = sort keys %$v;
640 0         0 my @ip;
641 0         0 for my $ik (@ik) {
642 0         0 my $iv = $v->{$ik};
643 0 0       0 push @ip, $ik . '=' . (defined $iv ? $iv : '');
644             }
645 0         0 push @pairs, $k . '={' . join(',', @ip) . '}';
646             } else {
647 26         81 push @pairs, $k . '=' . $v;
648             }
649             }
650              
651 9         80 return join(';', @pairs);
652             }
653              
654 0 0       0 if (ref($details) eq 'ARRAY') {
655 0         0 return '[' . join(',', @$details) . ']';
656             }
657              
658 0         0 return '' . $details;
659             }
660              
661             # ------------------------
662             # Deterministic serialization
663             # ------------------------
664              
665             sub to_json {
666 2     2 0 11 my ($self, %args) = @_;
667              
668 2 50       3 if (!exists $args{schema}) {
669 0         0 croak "to_json(): missing required named argument: schema";
670             }
671 2         3 my $schema = $args{schema};
672              
673 2         15 return _encode_json($schema, []);
674             }
675              
676             sub to_yaml {
677 2     2 0 843 my ($self, %args) = @_;
678              
679 2 50       5 if (!exists $args{schema}) {
680 0         0 croak "to_yaml(): missing required named argument: schema";
681             }
682 2         3 my $schema = $args{schema};
683              
684 2         20 return _encode_yaml($schema, 0, []);
685             }
686              
687             sub _encode_json {
688 74     74   91 my ($value, $path) = @_;
689              
690 74 50       99 if (!defined $value) {
691 0         0 return 'null';
692             }
693              
694 74         69 my $ref = ref($value);
695              
696 74 100       83 if ($ref eq '') {
697 50 100       109 if ($value =~ /\A-?(?:0|[1-9]\d*)\z/) {
698 30         61 return $value;
699             }
700 20         20 return _json_quote($value);
701             }
702              
703 24 100       29 if ($ref eq 'ARRAY') {
704 6         3 my @parts;
705 6         11 for my $i (0 .. $#$value) {
706 8         16 push @parts, _encode_json($value->[$i], [ @$path, $i ]);
707             }
708 6         18 return '[' . join(',', @parts) . ']';
709             }
710              
711 18 50       24 if ($ref eq 'HASH') {
712 18         22 my @keys = _ordered_keys_for_path($value, $path);
713 18         16 my @parts;
714 18         19 for my $k (@keys) {
715 64         67 my $v = $value->{$k};
716 64         63 push @parts, _json_quote($k) . ':' . _encode_json($v, [ @$path, $k ]);
717             }
718 18         76 return '{' . join(',', @parts) . '}';
719             }
720              
721 0         0 croak "to_json(): unsupported reference type: $ref";
722             }
723              
724             sub _json_quote {
725 84     84   84 my ($s) = @_;
726              
727 84         86 $s =~ s/\\/\\\\/g;
728 84         69 $s =~ s/\"/\\\"/g;
729              
730 84         79 $s =~ s/\n/\\n/g;
731 84         70 $s =~ s/\r/\\r/g;
732 84         68 $s =~ s/\t/\\t/g;
733 84         74 $s =~ s/\f/\\f/g;
734 84         69 $s =~ s/\x08/\\b/g;
735              
736 84         79 $s =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/ge;
  0         0  
737              
738 84         164 return '"' . $s . '"';
739             }
740              
741             sub _encode_yaml {
742 74     74   84 my ($value, $indent, $path) = @_;
743              
744 74         143 my $sp = ' ' x $indent;
745              
746 74 50       85 if (!defined $value) {
747 0         0 return "~\n";
748             }
749              
750 74         63 my $ref = ref($value);
751              
752 74 100       87 if ($ref eq '') {
753 50 100       102 if ($value =~ /\A-?(?:0|[1-9]\d*)\z/) {
754 30         52 return $value . "\n";
755             }
756 20         22 return _yaml_quote($value) . "\n";
757             }
758              
759 24 100       35 if ($ref eq 'ARRAY') {
760 6 100       9 if (!@$value) {
761 2         6 return "[]\n";
762             }
763              
764 4         3 my $out = '';
765 4         8 for my $i (0 .. $#$value) {
766 8         9 my $item = $value->[$i];
767 8         9 my $item_ref = ref($item);
768              
769 8 100 66     18 if (!defined $item || $item_ref eq '') {
770 4         12 $out .= $sp . '- ' . _chomp_one_line(_encode_yaml($item, 0, [ @$path, $i ]));
771             } else {
772 4         5 $out .= $sp . "-\n";
773 4         7 $out .= _indent_block(_encode_yaml($item, $indent + 2, [ @$path, $i ]), $indent + 2);
774             }
775             }
776 4         8 return $out;
777             }
778              
779 18 50       23 if ($ref eq 'HASH') {
780 18         21 my @keys = _ordered_keys_for_path($value, $path);
781              
782 18 50       26 if (!@keys) {
783 0         0 return "{}\n";
784             }
785              
786 18         16 my $out = '';
787 18         20 for my $k (@keys) {
788 64         66 my $v = $value->{$k};
789 64         73 my $v_ref = ref($v);
790              
791 64 100 66     120 if (!defined $v || $v_ref eq '') {
792 46         90 $out .= $sp . $k . ': ' . _chomp_one_line(_encode_yaml($v, 0, [ @$path, $k ]));
793             } else {
794 18         28 $out .= $sp . $k . ":\n";
795 18         32 $out .= _indent_block(_encode_yaml($v, $indent + 2, [ @$path, $k ]), $indent + 2);
796             }
797             }
798 18         41 return $out;
799             }
800              
801 0         0 croak "to_yaml(): unsupported reference type: $ref";
802             }
803              
804             sub _yaml_quote {
805 20     20   23 my ($s) = @_;
806 20         35 $s =~ s/'/''/g;
807 20         42 return "'" . $s . "'";
808             }
809              
810             sub _indent_block {
811 22     22   25 my ($text, $indent) = @_;
812 22         25 my $sp = ' ' x $indent;
813              
814 22         105 $text =~ s/^/$sp/gm;
815              
816 22         56 return $text;
817             }
818              
819             sub _chomp_one_line {
820 50     50   52 my ($s) = @_;
821 50         80 $s =~ s/\n\z//;
822 50         103 return $s . "\n";
823             }
824              
825             sub _ordered_keys_for_path {
826 36     36   41 my ($hash, $path) = @_;
827              
828 36         31 my %rank;
829              
830 36 100       55 if (!@$path) {
831 4         11 my @ordered = qw(
832             schema_version
833             generator
834             profile
835             source
836             options
837             columns
838             issues
839             notes
840             );
841 4         17 @rank{@ordered} = (0 .. $#ordered);
842 4         9 return _ranked_sort_keys($hash, \%rank);
843             }
844              
845 32 50 66     99 if (@$path >= 2 && $path->[0] eq 'columns' && $path->[1] =~ /\A\d+\z/) {
      66        
846 24         42 my @ordered = qw(
847             index
848             name
849             type
850             nullable
851             length
852             values
853             pattern
854             overrides
855             provenance
856             );
857 24         95 @rank{@ordered} = (0 .. $#ordered);
858 24         38 return _ranked_sort_keys($hash, \%rank);
859             }
860              
861 8 0 33     16 if (@$path >= 3 && $path->[0] eq 'columns' && $path->[2] eq 'length') {
      33        
862 0         0 my @ordered = qw(min max);
863 0         0 @rank{@ordered} = (0 .. $#ordered);
864 0         0 return _ranked_sort_keys($hash, \%rank);
865             }
866              
867 8 0 33     14 if (@$path >= 3 && $path->[0] eq 'columns' && $path->[2] eq 'overrides') {
      33        
868 0         0 my @ordered = qw(type nullable name length);
869 0         0 @rank{@ordered} = (0 .. $#ordered);
870 0         0 return _ranked_sort_keys($hash, \%rank);
871             }
872              
873 8 100 66     28 if (@$path >= 1 && $path->[0] eq 'generator') {
874 4         7 my @ordered = qw(name version);
875 4         9 @rank{@ordered} = (0 .. $#ordered);
876 4         5 return _ranked_sort_keys($hash, \%rank);
877             }
878              
879 4 50 33     14 if (@$path >= 1 && $path->[0] eq 'profile') {
880 4         27 my @ordered = qw(report_version null_empty null_tokens rows_profiled generated_by);
881 4         15 @rank{@ordered} = (0 .. $#ordered);
882 4         6 return _ranked_sort_keys($hash, \%rank);
883             }
884              
885 0 0 0     0 if (@$path >= 3 && $path->[0] eq 'columns' && $path->[2] eq 'provenance') {
      0        
886 0         0 my @ordered = qw(
887             basis
888             rows_observed
889             null_count
890             null_rate
891             distinct_count
892             min_length_observed
893             max_length_observed
894             overrides
895             );
896 0         0 @rank{@ordered} = (0 .. $#ordered);
897 0         0 return _ranked_sort_keys($hash, \%rank);
898             }
899              
900 0 0 0     0 if (@$path >= 4 && $path->[0] eq 'columns' && $path->[3] eq 'null_rate') {
      0        
901 0         0 my @ordered = qw(num den);
902 0         0 @rank{@ordered} = (0 .. $#ordered);
903 0         0 return _ranked_sort_keys($hash, \%rank);
904             }
905              
906 0 0 0     0 if (@$path >= 2 && $path->[0] eq 'issues' && $path->[1] =~ /\A\d+\z/) {
      0        
907 0         0 my @ordered = qw(level code message column_index details);
908 0         0 @rank{@ordered} = (0 .. $#ordered);
909 0         0 return _ranked_sort_keys($hash, \%rank);
910             }
911              
912 0         0 return sort keys %$hash;
913             }
914              
915             sub _ranked_sort_keys {
916 36     36   40 my ($hash, $rank) = @_;
917              
918             return sort {
919 36 100       85 my $ra = exists $rank->{$a} ? $rank->{$a} : 1_000_000;
  158         195  
920 158 100       165 my $rb = exists $rank->{$b} ? $rank->{$b} : 1_000_000;
921              
922 158   66     313 return $ra <=> $rb
923             || $a cmp $b;
924             } keys %$hash;
925             }
926              
927             =pod
928              
929             =encoding utf8
930              
931             =head1 NAME
932              
933             Flat::Schema - Deterministic schema contracts for flat files
934              
935             =head1 WHY THIS EXISTS (IN ONE PARAGRAPH)
936              
937             In real ETL work, yesterday's CSV becomes today's "contract" whether you meant it or not.
938             Flat::Schema makes that contract explicit: generate a deterministic schema from what you
939             observed, record ambiguity as issues, and give the next step (validation) something
940             stable to enforce.
941              
942             =head1 SYNOPSIS
943              
944             Basic usage:
945              
946             use Flat::Profile;
947             use Flat::Schema;
948              
949             my $profile = Flat::Profile->profile_file(
950             file => "data.csv",
951             );
952              
953             my $schema = Flat::Schema->from_profile(
954             profile => $profile,
955             );
956              
957             print Flat::Schema->new()->to_json(schema => $schema);
958              
959             With overrides:
960              
961             my $schema = Flat::Schema->from_profile(
962             profile => $profile,
963             overrides => [
964             { column_index => 0, set => { type => 'integer', nullable => 0 } },
965             { column_index => 3, set => { name => 'created_at', type => 'datetime' } },
966             ],
967             );
968              
969             =head1 DESCRIPTION
970              
971             Flat::Schema consumes reports produced by L and generates a
972             deterministic, inspectable schema contract describing what tabular data
973             B look like.
974              
975             It is the second module in the Flat::* series:
976              
977             =over 4
978              
979             =item *
980              
981             Flat::Profile — What the data looks like
982              
983             =item *
984              
985             Flat::Schema — What the data should look like
986              
987             =item *
988              
989             Flat::Validate — Does the data conform (planned)
990              
991             =back
992              
993             The schema is a canonical Perl data structure that:
994              
995             =over 4
996              
997             =item *
998              
999             Is stable and deterministic (identical inputs → identical output)
1000              
1001             =item *
1002              
1003             Is serializable to JSON and YAML
1004              
1005             =item *
1006              
1007             Captures inference decisions and ambiguity as issues
1008              
1009             =item *
1010              
1011             Can be consumed by Flat::Validate or other tooling
1012              
1013             =back
1014              
1015             =head1 REAL-WORLD USE CASES (THE STUFF YOU ACTUALLY DO)
1016              
1017             =head2 1) Vendor “helpfully” changes a column (integer → text)
1018              
1019             You ingest daily files and one day a numeric column starts containing
1020             values like C, C, or C. Your pipeline should not silently
1021             coerce this into zero or drop rows.
1022              
1023             Workflow:
1024              
1025             =over 4
1026              
1027             =item 1.
1028              
1029             Profile last-known-good
1030              
1031             =item 2.
1032              
1033             Generate schema (your contract)
1034              
1035             =item 3.
1036              
1037             Validate future drops against the schema
1038              
1039             =back
1040              
1041             A typical override when you decide "we accept this as string now":
1042              
1043             my $schema = Flat::Schema->from_profile(
1044             profile => $profile,
1045             overrides => [
1046             { column_index => 7, set => { type => 'string' } },
1047             ],
1048             );
1049              
1050             Flat::Schema will record that the override conflicts with what it inferred, and
1051             that record is useful during incident review.
1052              
1053             =head2 2) Columns that are “nullable in real life” even if today they are not
1054              
1055             Data often arrives complete in a sample window and then starts missing values
1056             in production. In v1, nullability is intentionally simple:
1057              
1058             nullable = true iff null_count > 0
1059              
1060             If you know a field is nullable even if today it isn't, force it:
1061              
1062             overrides => [
1063             { column_index => 2, set => { nullable => 1 } }, # allow missing later
1064             ],
1065              
1066             =head2 3) Timestamp confusion: date vs datetime vs “whatever the exporter did”
1067              
1068             When temporal evidence mixes, Flat::Schema chooses predictability over cleverness.
1069              
1070             =over 4
1071              
1072             =item *
1073              
1074             date + datetime → datetime
1075              
1076             =item *
1077              
1078             temporal + non-temporal → string (and it tells you)
1079              
1080             =back
1081              
1082             This prevents “maybe parseable” data from becoming quietly wrong later.
1083              
1084             =head2 4) “Header row roulette” and naming cleanup
1085              
1086             You may get headers like C, C, C, or no header at all.
1087             Schema stores both:
1088              
1089             =over 4
1090              
1091             =item *
1092              
1093             C always
1094              
1095             =item *
1096              
1097             C when available
1098              
1099             =back
1100              
1101             If you need normalized naming for downstream systems:
1102              
1103             overrides => [
1104             { column_index => 0, set => { name => 'customer_id' } },
1105             ],
1106              
1107             =head2 5) Reproducible artifacts for tickets, audits, and “what changed?”
1108              
1109             Sometimes the most important feature is being able to paste the schema into a ticket,
1110             diff it in Git, or keep it as a build artifact.
1111              
1112             Flat::Schema’s serializers are deterministic by design. If the schema changes, it is
1113             because the inputs changed (profile or overrides), not because hash order shifted.
1114              
1115             =head1 SCHEMA STRUCTURE (AT A GLANCE)
1116              
1117             A generated schema contains:
1118              
1119             {
1120             schema_version => 1,
1121             generator => { name => "Flat::Schema", version => "0.01" },
1122             profile => { ... },
1123             columns => [ ... ],
1124             issues => [ ... ],
1125             }
1126              
1127             Each column contains:
1128              
1129             {
1130             index => 0,
1131             name => "id",
1132             type => "integer",
1133             nullable => 0,
1134             length => { min => 1, max => 12 }, # optional
1135             overrides => { ... }, # optional
1136             provenance => {
1137             basis => "profile",
1138             rows_observed => 1000,
1139             null_count => 0,
1140             null_rate => { num => 0, den => 1000 },
1141             overrides => [ "type", "nullable" ], # optional
1142             },
1143             }
1144              
1145             =head1 TYPE INFERENCE (v1)
1146              
1147             Type inference is based solely on evidence provided by Flat::Profile.
1148              
1149             Scalar widening order:
1150              
1151             boolean → integer → number → string
1152              
1153             Temporal handling:
1154              
1155             date + datetime → datetime
1156             temporal + non-temporal → string (with warning)
1157              
1158             Mixed evidence is widened and recorded as an issue.
1159              
1160             =head1 NULLABILITY INFERENCE (v1)
1161              
1162             Rules:
1163              
1164             =over 4
1165              
1166             =item *
1167              
1168             nullable = true iff null_count > 0
1169              
1170             =item *
1171              
1172             If rows_profiled == 0, all columns are nullable
1173              
1174             =item *
1175              
1176             All-null columns emit warning C
1177              
1178             =item *
1179              
1180             Zero profiled rows emits warning C
1181              
1182             =back
1183              
1184             =head1 USER OVERRIDES (v1)
1185              
1186             Overrides are applied after inference.
1187              
1188             Supported fields:
1189              
1190             =over 4
1191              
1192             =item *
1193              
1194             type
1195              
1196             =item *
1197              
1198             nullable
1199              
1200             =item *
1201              
1202             name
1203              
1204             =item *
1205              
1206             length (min/max)
1207              
1208             =back
1209              
1210             Overrides:
1211              
1212             =over 4
1213              
1214             =item *
1215              
1216             Are index-based (column_index required)
1217              
1218             =item *
1219              
1220             May conflict with inferred values (recorded as warnings)
1221              
1222             =item *
1223              
1224             Are recorded in column.overrides
1225              
1226             =item *
1227              
1228             Are recorded in provenance.overrides
1229              
1230             =item *
1231              
1232             Emit an informational C issue
1233              
1234             =back
1235              
1236             Overrides referencing unknown columns cause a hard error.
1237              
1238             =head1 DETERMINISTIC SERIALIZATION
1239              
1240             Flat::Schema includes built-in deterministic JSON and YAML serializers.
1241              
1242             Same input profile + same overrides → identical JSON/YAML.
1243              
1244             This is required for reproducible pipelines and meaningful diffs.
1245              
1246             =head1 STATUS
1247              
1248             Implemented in v1:
1249              
1250             =over 4
1251              
1252             =item *
1253              
1254             Canonical schema structure
1255              
1256             =item *
1257              
1258             Deterministic serialization
1259              
1260             =item *
1261              
1262             Type inference
1263              
1264             =item *
1265              
1266             Nullability inference
1267              
1268             =item *
1269              
1270             User overrides (index-based)
1271              
1272             =back
1273              
1274             Future releases may expand the type lattice, constraint modeling, and schema evolution.
1275              
1276             =head1 AUTHOR
1277              
1278             Sergio de Sousa
1279              
1280             =head1 LICENSE
1281              
1282             This library is free software; you may redistribute it and/or modify
1283             it under the same terms as Perl itself.
1284              
1285             =cut
1286              
1287              
1288             1;