File Coverage

blib/lib/DBIx/Result/Convert/JSONSchema.pm
Criterion Covered Total %
statement 100 136 73.5
branch 47 72 65.2
condition 43 67 64.1
subroutine 9 10 90.0
pod 1 1 100.0
total 200 286 69.9


line stmt bran cond sub pod time code
1             package DBIx::Result::Convert::JSONSchema;
2              
3             our $VERSION = '0.05';
4              
5              
6             =head1 NAME
7              
8             DBIx::Result::Convert::JSONSchema - Convert DBIx result schema to JSON schema
9              
10             =begin html
11              
12             Build Status
13             Coverage Status
14              
15             =end html
16              
17             =head1 VERSION
18              
19             0.05
20              
21             =head1 SYNOPSIS
22              
23             use DBIx::Result::Convert::JSONSchema;
24              
25             my $SchemaConvert = DBIx::Result::Convert::JSONSchema->new( schema => Schema );
26             my $json_schema = $SchemaConvert->get_json_schema( DBIx::Class::ResultSource );
27              
28             =head1 DESCRIPTION
29              
30             This module attempts basic conversion of L to equivalent
31             of L.
32             By default the conversion assumes that the L originated
33             from MySQL database. Thus all the types and defaults are set based on MySQL
34             field definitions.
35             It is, however, possible to overwrite field type map and length map to support
36             L from other database solutions.
37              
38             Note, relations between tables are not taken in account!
39              
40             =cut
41              
42              
43 4     4   5094 use Moo;
  4         38098  
  4         19  
44 4     4   6854 use Types::Standard qw/ InstanceOf Enum HashRef /;
  4         257954  
  4         48  
45              
46 4     4   3811 use Carp;
  4         9  
  4         243  
47 4     4   1719 use Module::Load qw/ load /;
  4         3984  
  4         29  
48              
49              
50             has schema => (
51             is => 'ro',
52             isa => InstanceOf['DBIx::Class::Schema'],
53             required => 1,
54             );
55              
56             has schema_source => (
57             is => 'lazy',
58             isa => Enum[ qw/ MySQL / ],
59             default => 'MySQL',
60             );
61              
62             has length_type_map => (
63             is => 'rw',
64             isa => HashRef,
65             default => sub {
66             return {
67             string => [ qw/ minLength maxLength / ],
68             number => [ qw/ minimum maximum / ],
69             integer => [ qw/ minimum maximum / ],
70             };
71             },
72             );
73              
74             has type_map => (
75             is => 'rw',
76             isa => HashRef,
77             default => sub {
78             my ( $self ) = @_;
79              
80             my $type_class = __PACKAGE__ . '::Type::' . $self->schema_source;
81             load $type_class;
82              
83             return $type_class->get_type_map;
84             },
85             );
86              
87             has length_map => (
88             is => 'rw',
89             isa => HashRef,
90             default => sub {
91             my ( $self ) = @_;
92              
93             my $defaults_class = __PACKAGE__ . '::Default::' . $self->schema_source;
94             load $defaults_class;
95              
96             return $defaults_class->get_length_map;
97             },
98             );
99              
100             has pattern_map => (
101             is => 'rw',
102             isa => HashRef,
103             lazy => 1,
104             default => sub {
105             return {
106             time => '^\d{2}:\d{2}:\d{2}$',
107             year => '^\d{4}$',
108             datetime => '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$',
109             timestamp => '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$',
110             };
111             }
112             );
113              
114             has format_map => (
115             is => 'rw',
116             isa => HashRef,
117             lazy => 1,
118             default => sub {
119             return {
120             date => 'date',
121             };
122             }
123             );
124              
125              
126             =head2 get_json_schema
127              
128             Returns somewhat equivalent JSON schema based on DBIx result source name.
129              
130             my $json_schema = $converted->get_json_schema( 'TableSource', {
131             schema_declaration => 'http://json-schema.org/draft-04/schema#',
132             decimals_to_pattern => 1,
133             has_schema_property_description => 1,
134             allow_additional_properties => 0,
135             ignore_property_defaults => 1,
136             overwrite_schema_property_keys => {
137             name => 'cat',
138             address => 'dog',
139             },
140             add_schema_properties => {
141             address => { ... },
142             bank_account => '#/definitions/bank_account',
143             },
144             overwrite_schema_properties => {
145             name => {
146             _action => 'merge', # one of - merge/overwrite
147             minimum => 10,
148             maximum => 20,
149             type => 'number',
150             },
151             },
152             include_required => [ qw/ street city / ],
153             exclude_required => [ qw/ name address / ],
154             exclude_properties => [ qw/ mouse house / ],
155              
156             dependencies => {
157             first_name => [ qw/ middle_name last_name / ],
158             },
159             });
160              
161             Optional arguments to change how JSON schema is generated:
162              
163             =over 8
164              
165             =item * schema_declaration
166              
167             Declare which version of the JSON Schema standard that the schema was written against.
168              
169             L
170              
171             B: "http://json-schema.org/schema#"
172              
173             =item * decimals_to_pattern
174              
175             1/0 - value to indicate if 'number' type field should be converted to 'string' type with
176             RegExp pattern based on decimal place definition in database.
177              
178             B: 0
179              
180             =item * has_schema_property_description
181              
182             Generate schema description for fields e.g. 'Optional numeric type value for field context e.g. 1'.
183              
184             B: 0
185              
186             =item * ignore_property_defaults
187              
188             Do not set schema B property field based on default in DBIx schema
189              
190             B: 0
191              
192             =item * allow_additional_properties
193              
194             Define if the schema accepts additional keys in given payload.
195              
196             B: 0
197              
198             =item * add_property_minimum_value
199              
200             If field does not have format type add minimum values for number and string types based on DB field type.
201             This might not make sense in most cases as the minimum is either 0 or the lower bound if number is signed.
202              
203             B: 0
204              
205             =item * overwrite_schema_property_keys
206              
207             HashRef representing mapping between old property name and new property name to overwrite existing schema keys,
208             Properties from old key will be assigned to the new property.
209              
210             B The key conversion is executed last, every other option e.g. C will work only on original
211             database column names.
212              
213             =item * overwrite_schema_properties
214              
215             HashRef of property name and new attributes which can be either overwritten or merged based on given B<_action> key.
216              
217             =item * exclude_required
218              
219             ArrayRef of database column names which should always be EXCLUDED from REQUIRED schema properties.
220              
221             =item * include_required
222              
223             ArrayRef of database column names which should always be INCLUDED in REQUIRED schema properties
224              
225             =item * exclude_properties
226              
227             ArrayRef of database column names which should be excluded from JSON schema AT ALL
228              
229             =item * dependencies
230              
231             L
232              
233             =item * add_schema_properties
234              
235             HashRef of custom schema properties that must be included in final definition
236             Note that custom properties will overwrite defaults
237              
238             =item * schema_overwrite
239              
240             HashRef of top level schema properties e.g. 'required', 'properties' etc. to overwrite
241              
242             =back
243              
244             =cut
245              
246             sub get_json_schema {
247 9     9 1 40250 my ( $self, $source, $args ) = @_;
248              
249 9 100       39 croak 'missing schema source' unless $source;
250              
251 8   100     24 $args //= {};
252              
253             # additional schema generation options
254 8         15 my $decimals_to_pattern = $args->{decimals_to_pattern};
255 8         15 my $has_schema_property_description = $args->{has_schema_property_description};
256 8         15 my $ignore_property_defaults = $args->{ignore_property_defaults};
257 8   100     33 my $overwrite_schema_property_keys = $args->{overwrite_schema_property_keys} // {};
258 8         24 my $add_schema_properties = $args->{add_schema_properties};
259 8   100     29 my $overwrite_schema_properties = $args->{overwrite_schema_properties} // {};
260 8         15 my $add_property_minimum_value = $args->{add_property_minimum_value};
261 8 100       17 my %exclude_required = map { $_ => 1 } @{ $args->{exclude_required} || [] };
  1         4  
  8         31  
262 8 100       16 my %include_required = map { $_ => 1 } @{ $args->{include_required} || [] };
  2         4  
  8         30  
263 8 100       12 my %exclude_properties = map { $_ => 1 } @{ $args->{exclude_properties} || [] };
  1         3  
  8         36  
264              
265 8         15 my $dependencies = $args->{dependencies};
266 8   100     44 my $schema_declaration = $args->{schema_declaration} // 'http://json-schema.org/schema#';
267 8   100     28 my $allow_additional_properties = $args->{allow_additional_properties} // 0;
268 8   100     24 my $schema_overwrite = $args->{schema_overwrite} // {};
269              
270 8 100       56 my %json_schema = (
271             '$schema' => $schema_declaration,
272             type => 'object',
273             required => [],
274             properties => {},
275             additionalProperties => $allow_additional_properties,
276              
277             ( $dependencies ? ( dependencies => $dependencies ) : () ),
278             );
279              
280 8         23 my $source_info = $self->_get_column_info( $source );
281              
282             SCHEMA_COLUMN:
283 7         896 foreach my $column ( keys %{ $source_info } ) {
  7         33  
284 196 100       320 next SCHEMA_COLUMN if $exclude_properties{ $column };
285              
286 195         269 my $column_info = $source_info->{ $column };
287              
288             # DBIx schema data type -> JSON schema data type
289             my $json_type = $self->type_map->{ $column_info->{data_type} }
290             or croak sprintf(
291             'unknown data type - %s (source: %s, column: %s)',
292 195 50       2632 $column_info->{data_type}, $source, $column
293             );
294              
295 195         1234 $json_schema{properties}->{ $column }->{type} = $json_type;
296              
297             # DBIx schema type -> JSON format
298 195         2597 my $format_type = $self->format_map->{ $column_info->{data_type} };
299 195 100       1055 if ( $format_type ) {
300 7         16 $json_schema{properties}->{ $column }->{format} = $format_type;
301             }
302              
303             # DBIx schema size constraint -> JSON schema size constraint
304 195 100 100     2425 if ( ! $format_type && $self->length_map->{ $column_info->{data_type} } ) {
305 139         1467 $self->_set_json_schema_property_range( \%json_schema, $column_info, $column, $add_property_minimum_value );
306             }
307              
308             # DBIx schema required -> JSON schema required
309 195         716 my $is_required_field = $include_required{ $column };
310 195 100 100     772 if ( $is_required_field || ( ! $column_info->{default_value} && ! $column_info->{is_nullable} && ! $exclude_required{ $column } ) ) {
      100        
311 8   33     27 my $required_property = $overwrite_schema_property_keys->{ $column } // $column;
312 8         13 push @{ $json_schema{required} }, $required_property;
  8         17  
313             }
314              
315             # DBIx schema defaults -> JSON schema defaults (no refs e.g. current_timestamp)
316 195 100 100     514 if ( ! $ignore_property_defaults && $column_info->{default_value} && ! ref $column_info->{default_value} ) {
      100        
317 6         14 $json_schema{properties}->{ $column }->{default} = $column_info->{default_value};
318             }
319              
320             # DBIx schema list -> JSON enum list
321 195 50 66     382 if ( $json_type eq 'enum' && $column_info->{extra} && $column_info->{extra}->{list} ) { # no autovivification
      33        
322 14         42 $json_schema{properties}->{ $column }->{enum} = $column_info->{extra}->{list};
323             }
324              
325             # Consider 'is nullable' to accept 'null' values in all cases where field is not explicitly required
326 195 100 100     467 if ( ! $is_required_field && $column_info->{is_nullable} ) {
327 172 100       261 if ( $json_type eq 'enum' ) {
328 14   50     30 $json_schema{properties}->{ $column }->{enum} //= [];
329 14         19 push @{ $json_schema{properties}->{ $column }->{enum} }, 'null';
  14         43  
330             }
331             else {
332 158         331 $json_schema{properties}->{ $column }->{type} = [ $json_type, 'null' ];
333             }
334             }
335              
336             # DBIx decimal numbers -> JSON schema numeric string pattern
337 195 100 100     391 if ( $json_type eq 'number' && $decimals_to_pattern ) {
338 4 100 66     14 if ( $column_info->{size} && ref $column_info->{size} eq 'ARRAY' ) {
339 1         10 $json_schema{properties}->{ $column }->{type} = 'string';
340 1         5 $json_schema{properties}->{ $column }->{pattern} = $self->_get_decimal_pattern( $column_info->{size} );
341             }
342             }
343              
344             # JSON schema field patterns
345 195 100       2711 if ( $self->pattern_map->{ $column_info->{data_type} } ) {
346 30         493 $json_schema{properties}->{ $column }->{pattern} = $self->pattern_map->{ $column_info->{data_type} };
347             }
348              
349             # JSON schema property description
350 195 50 33     1386 if ( ! $json_schema{properties}->{ $column }->{description} && $has_schema_property_description ) {
351             my $property_description = $self->_get_json_schema_property_description(
352             $overwrite_schema_property_keys->{ $column } // $column,
353 0   0     0 $json_schema{properties}->{ $column }
354             );
355 0         0 $json_schema{properties}->{ $column }->{description} = $property_description;
356             }
357              
358             # JSON schema custom additional properties
359 195 50       303 if ( $add_schema_properties ) {
360 0         0 foreach my $property_key ( keys %{ $add_schema_properties } ) {
  0         0  
361 0         0 $json_schema{properties}->{ $property_key } = $add_schema_properties->{ $property_key };
362             }
363             }
364              
365             # Overwrites: merge JSON schema property key values with custom ones
366 195 100       323 if ( my $overwrite_property = delete $overwrite_schema_properties->{ $column } ) {
367 2   50     5 my $action = delete $overwrite_property->{_action} // 'merge';
368              
369             $json_schema{properties}->{ $column } = {
370 2 100       8 %{ $action eq 'merge' ? $json_schema{properties}->{ $column } : {} },
371 2         4 %{ $overwrite_property }
  2         9  
372             };
373             }
374              
375             # Overwrite: replace JSON schema keys
376 195 100       394 if ( my $new_key = $overwrite_schema_property_keys->{ $column } ) {
377 2         6 $json_schema{properties}->{ $new_key } = delete $json_schema{properties}->{ $column };
378             }
379             }
380              
381             return {
382             %json_schema,
383 7         31 %{ $schema_overwrite },
  7         72  
384             };
385             }
386              
387             # Return DBIx result source column info for the given result class name
388             sub _get_column_info {
389 10     10   27174 my ( $self, $source ) = @_;
390              
391 10         90 return $self->schema->source($source)->columns_info;
392             }
393              
394             # Returns RegExp pattern for decimal numbers based on database field definition
395             sub _get_decimal_pattern {
396 1     1   4 my ( $self, $size ) = @_;
397              
398 1         1 my ( $x, $y ) = @{ $size };
  1         3  
399 1         7 return sprintf '^\d{1,%s}\.\d{0,%s}$', $x - $y, $y;
400             }
401              
402             # Generates somewhat logical field description based on type and length constraints
403             sub _get_json_schema_property_description {
404 0     0   0 my ( $self, $column, $property ) = @_;
405              
406 0 0       0 if ( ! $property->{type} ) {
407 0 0       0 if ( $property->{enum} ) {
408 0         0 return sprintf 'Enum list type, one of - %s', join( ', ', @{ $property->{enum} } );
  0         0  
409             }
410              
411 0         0 return '';
412             }
413              
414 0 0       0 return '' if $property->{type} eq 'object'; # no idea how to handle
415              
416 0         0 my %types;
417 0 0       0 if ( ref $property->{type} eq 'ARRAY' ) {
418 0         0 %types = map { $_ => 1 } @{ $property->{type} };
  0         0  
  0         0  
419             }
420             else {
421 0         0 $types{ $property->{type} } = 1;
422             }
423              
424 0         0 my $description = '';
425 0 0       0 $description .= 'Optional' if $types{null};
426              
427 0         0 my $type_part;
428 0 0       0 if ( grep { /^integer|number$/ } keys %types ) {
  0         0  
429 0         0 $type_part = 'numeric';
430             }
431             else {
432 0         0 ( $type_part ) = grep { $_ ne 'null' } keys %types; # lucky roll, last type that isn't 'null' should be legit
  0         0  
433             }
434              
435 0 0       0 $description .= $description ? " $type_part" : ucfirst $type_part;
436 0         0 $description .= sprintf ' type value for field %s', $column;
437              
438 0 0 0     0 if ( ( grep { /^integer$/ } keys %types ) && $property->{maximum} ) {
  0 0 0     0  
439 0   0     0 my $integer_example = $property->{default} // int rand $property->{maximum};
440 0         0 $description .= ' e.g. ' . $integer_example;
441             }
442 0         0 elsif ( ( grep { /^string$/ } keys %types ) && $property->{pattern} ) {
443 0         0 $description .= sprintf ' with pattern %s ', $property->{pattern};
444             }
445              
446 0         0 return $description;
447             }
448              
449             # Convert from DBIx field length to JSON schema field length based on field type
450             sub _set_json_schema_property_range {
451 139     139   253 my ( $self, $json_schema, $column_info, $column, $add_property_minimum_value ) = @_;
452              
453 139         1737 my $json_schema_min_type = $self->length_type_map->{ $self->type_map->{ $column_info->{data_type} } }->[0];
454 139         4058 my $json_schema_max_type = $self->length_type_map->{ $self->type_map->{ $column_info->{data_type} } }->[1];
455              
456 139         2538 my $json_schema_min = $self->_get_json_schema_property_min_max_value( $column_info, 0 );
457 139         4571 my $json_schema_max = $self->_get_json_schema_property_min_max_value( $column_info, 1 );
458              
459             # bump min value to 1 (don't see how this starts from negative)
460 139 50       4308 $json_schema_min = 1 if $column_info->{is_auto_increment};
461              
462 139 100       243 $json_schema->{properties}->{ $column }->{ $json_schema_min_type } = $json_schema_min
463             if $add_property_minimum_value;
464 139         262 $json_schema->{properties}->{ $column }->{ $json_schema_max_type } = $json_schema_max;
465              
466 139 100       223 if ( $column_info->{size} ) {
467 34         63 $json_schema->{properties}->{ $column }->{ $json_schema_max_type } = $column_info->{size};
468             }
469              
470 139         222 return;
471             }
472              
473             # Returns min/max value from DBIx result field definition or lookup from defaults
474             sub _get_json_schema_property_min_max_value {
475 278     278   428 my ( $self, $column_info, $range ) = @_;
476              
477 278 0 33     494 if ( $column_info->{extra} && $column_info->{extra}->{unsigned} ) { # no autovivification
478 0         0 return $self->length_map->{ $column_info->{data_type} }->{unsigned}->[ $range ];
479             }
480              
481             return ref $self->length_map->{ $column_info->{data_type} } eq 'ARRAY' ? $self->length_map->{ $column_info->{data_type} }->[ $range ]
482 278 100       3464 : $self->length_map->{ $column_info->{data_type} }->{signed}->[ $range ];
483             }
484              
485             =head1 SEE ALSO
486              
487             L - Result source object
488              
489             =head1 AUTHOR
490              
491             malishew - C
492              
493             =head1 LICENSE
494              
495             This library is free software; you can redistribute it and/or modify it under
496             the same terms as Perl itself. If you would like to contribute documentation
497             or file a bug report then please raise an issue / pull request:
498              
499             https://github.com/Humanstate/p5-dbix-result-convert-jsonschema
500              
501             =cut
502              
503             __PACKAGE__->meta->make_immutable;