damil/Data-Domain

optional key in Struct but the value can't be undef

Closed this issue · 19 comments

i think im losing my mind here, how do i achieve the following with Data::Domain?

I want to allow { } but { name => undef } should fail.

so name can be omitted from the input HASH but it cant be undef.

tests dont seem to cover this case: t/data-domain.t

Code

use strict;
use warnings;
use feature qw/say/;
use Data::Dumper;
use Data::Domain qw/:all/;

my $domain = Struct(
                     -exclude => '*',
                     -fields => {
                                  name => String(
                                                  -regex => qr/^[a-z0-9]{2,10}$/,
                                                  -optional => 1,
                                                )
                                }
);

my @values = (
    {},
    { name => undef },
);

for my $v (@values) {
    my $err = $domain->inspect($v);
    say '------------';
    print 'input:   ' . Dumper $v;
    print 'success: ';
    say $err ? 'NOTOK' : 'OK';
    print 'details: ' . Dumper $err if $err;
}

output

------------
input:   $VAR1 = {};
success: OK
------------
input:   $VAR1 = {
          'name' => undef
        };
success: OK
damil commented

Hi,

Thanks for your question, it's an interesting challenge.

The doc for "-optional" says "If true, the domain will accept undef, without generating an error message" ... so indeed, {name => undef} is accepted.

The current API for Struct() has no support for differentiating between a hash key containg undef or a hash key being absent. Usually this is not a problem because with %hash = (present => undef), expressions $hash{present} or $hash{absent} produce the same result. But your point is good : it would be nice to have an additional option for checking if a hash key exists or not. I'll try to to add this feature in the next version, but designing the proper API requires some thinking.

Meanwhile, what you can do is to add a condition requiring that all hash values must be defined :

my $domain = Struct(
                     -exclude => '*',
                     -fields => {
                                  name => String(
                                                  -regex => qr/^[a-z0-9]{2,10}$/,
                                                  -optional => 1,
                                                )
                                },
                     -values => List(-all => Whatever(-defined => 1)),
);

This should achieve the desired behaviour.

Best regards, Laurent

hi damil, thank you for the response.

yes your workaround seems to solve my issue. small remark ... the error message is quite cryptic with the workaround.

details: $VAR1 = {
          '-values' => [
                         'Whatever: data defined/undefined',
                         undef
                       ]
        };
use strict;
use warnings;
use feature qw/say/;
use Data::Dumper;
use Data::Domain qw/:all/;

my $domain = Struct(
                     -exclude => '*',
                     -fields => {
                                  first_name=> String(
                                                  -regex => qr/^[a-z0-9]{2,8}$/,
                                                  -optional => 1,
                                                ),
                                  last_name => String(
                                                  -regex => qr/^[a-z0-9]{2,8}$/,
                                                  -optional => 1,
                                                ),
                                },
                     -values => List(-all => Whatever(-defined => 1)),
);

my @values = (
    undef,
    {},
    { first_name=> undef },
    { first_name=> 'a' },
    { first_name=> 'abc', last_name => undef },
    { first_name=> 'abc', last_name => 'def' },
    { first_name=> undef, last_name => 'def' },
);

for my $v (@values) {
    my $err = $domain->inspect($v);
    say '------------';
    print 'input:  ' . Dumper $v;
    print 'success: ';
    say $err ? 'NOTOK' : 'OK';
    print 'details: ' . Dumper $err if $err;
}

Did you make a note of this issue somewhere else when you closed it? The workaround you described, while helping, does not really solve the issue at hand and the feedback that you get from the error messages are very unintuitive for a workflow where say.

{
   first_name => 'required',
   last_name => 'required' 
}

is required for creating a resource and

{
   first_name => 'optional_but_never_undef',
   last_name => 'optional_but_never_undef' 
}

for an update of an existing resource.

damil commented

Well, I would like to add a kind of -exists condition, but so far I didn't find a syntax that would be pleasant and implementable.
The best candidate would be something like

Struct(-fields => {first_name => [-if_exists => String(...)],
                   last_name => ...})

but it is kind of awkward.

For the workaround, you could define your own message

my $domain = Struct(
  -exclude => '*',
  -fields => {
    name => String(-regex => qr/^[a-z0-9]{2,10}$/, -optional => 1),
  },
  -values => List(-all => Defined(-messages => "can't be undef")),
 );

but still this is not very satisfactory.

Another workaround would be to use a lazy domain

my $domain = Struct(
  -exclude => '*',
  -fields => {
    name => sub {my $context = shift;
                 exists $context->{flat}{name} ? String( -regex => qr/^[a-z0-9]{2,10}$/) : Whatever},

  },
 );

can you reopen this issue? it doesnt seem to deserve a closed status yet.

damil commented

ok reopened

additional info, I only want some of the Struct members to exist mandatorily. so passing -defined for -values doesnt seem to work.

I played around and added a -mandatory attribute to Data::Domain::Struct that takes a arrayref.
this doesnt break the existing API.

$ diff -u Domain.pm Domain.pm.orig 
--- Domain.pm   2023-11-30 18:34:34.346956963 +0000
+++ Domain.pm.orig      2023-11-29 09:49:43.179438416 +0000
@@ -1156,12 +1156,11 @@
 use warnings;
 use Carp;
 use Scalar::Does qw/does/;
-use List::MoreUtils qw/any/;
 our @ISA = 'Data::Domain';
 
 sub new {
   my $class = shift;
-  my @options = qw/-fields -exclude -keys -values -mandatory/;
+  my @options = qw/-fields -exclude -keys -values/;
   my $self = Data::Domain::_parse_args(\@_, \@options, -fields => 'arrayref');
   bless $self, $class;
 
@@ -1230,19 +1229,13 @@
                 path => []};
   local $context->{flat} = {%{$context->{flat}}, %$data};
 
-  my $mandatory = $self->{-mandatory} || [];
   # check fields of the domain
   foreach my $field (@{$self->{-fields_list}}) {
-    if ( (any { $_ eq $field } $mandatory->@*) && !exists $data->{$field} ) {
-      $msgs{$field} = 'mandatory.';
-    }
-    else {
-      local $context->{path} = [@{$context->{path}}, $field];
-      my $field_spec = $self->{-fields}{$field};
-      my $subdomain  = $self->_build_subdomain($field_spec, $context);
-      my $msg        = $subdomain->inspect($data->{$field}, $context);
-      $msgs{$field}  = $msg if $msg;
-    }
+    local $context->{path} = [@{$context->{path}}, $field];
+    my $field_spec = $self->{-fields}{$field};
+    my $subdomain  = $self->_build_subdomain($field_spec, $context);
+    my $msg        = $subdomain->inspect($data->{$field}, $context);
+    $msgs{$field}  = $msg if $msg;
   }
damil commented

Hi,
Thanks for the proposal.
Yes, the Struct domain should have one additional information to distinguish which fields are mandatory or not.
An additional arrayref could do the job, I was considering something like that. But a list of mandatory fields would break backward compatibility for clients that currently use Struct(field_name => SomeDomain(-optional => 1)). A list of non-mandatory fields would do, but it would be confusing with respect to the existing -optional flag.
So far the best variant I could imagine would be something like

Struct(
  a => SomeDomain,                   # must exist, must be in SomeDomain
  b => SomeDomain(-optional => 1),   # can be absent or can be undef or must be in SomeDomain
  c => [-if_exists => SomeDomain],   # can be absent or must be in SomeDomain; cannot be undef
)

-optional only behaves differently if you actually add the -mandatory flag. without it everything works as usual. breaking backward compatibility would mean the same code breaks when Data::Domain is updated.

your -if_exists proposal seems to make it hard to reuse existing Data::Domain objects. with my -mandatory proposal you can use the same MyDomain() object and change it by passing MyDomain(-mandatory => qw [...]) you cant do that with if_exists, right?

especially for a use case where you want to use the same Data::Domain object for CREATE as well as UPDATE.

sub MyDomain {
   Struct(-fields => {
                                   first_name      => String(-min_length => 3),
                                   middle_name => String(-min_length => 3, -optional => 1),
                                   last_name       => String(-min_length => 3),
                                }
   );
}

#use this for CREATE 
MyDomain(-mandatory => [qw(first_name last_name)]);

#use this for UPDATE 
MyDomain();
damil commented

Sorry, I don't see the point. In the current version, fields are already mandatory by default. You have to explicitly say -optional for fields that are not mandatory. But this "optional" means that the field can either be absent or can be undef, which is what you want to avoid. So we need a syntax for specifying that we want an "exists" test.

damil commented

But I like your idea of having different domains for CREATE and for UPDATE -- just need to find the proper way to accomplish that

damil commented

Have a look at the last commits.
I added a -may_ignore option; it can take either an arrayref as you suggested, but also a regex or the '*' constant, like the -exclude option. There are some examples in the doc.
Waiting for your feedback before publishing to CPAN

ill get back to you tomorrow probably, thank you for your effort.

Struct(
  -fields => { last_name => String() },
  -may_ignore => '*',
);

the error message for

{ last_name => undef }

and

{ }

is the same.

'last_name' => 'String: undefined data'

I hope for a way to distinguish these two cases. the current error messages means to me that last_name was provided but undefined.

UPDATE: sorry, there is an error in this example, the same error message only occurs if you DONT set -may_ignore

damil commented

Something must be wrong; can you show me your code ?

Here is my test :

use strict;
use warnings;
use feature 'say';
use Data::Domain qw/:all/;
use Data::Dumper;

say "using Data::Domain version $Data::Domain::VERSION";

my $dom = Struct(-fields => { last_name => String() }, -may_ignore => '*');

my %tests = (normal    => { last_name => 'Beethoven' },
             undefined => { last_name => undef },
             absent    => {},
            );

while (my ($name, $data) = each %tests) {
  my $msg = $dom->inspect($data);
  say "$name : ", $msg ? Dumper($msg) : 'OK';
}

yielding result

using Data::Domain version 1.13
normal : OK
absent : OK
undefined : $VAR1 = {
          'last_name' => 'String: undefined data'
        };

my bad, what I mean is that if you dont set -may_ignore Data::Domain cannot distinguish between undef and not-existing.

if you remove the -may_ignore setting from your example you get the following output

undefined : $VAR1 = {
          'last_name' => 'String: undefined data'
        };

absent : $VAR1 = {
          'last_name' => 'String: undefined data'
        };

same error message for clearly different input.

damil commented

yes, sure, this is what you get with the current version, because the initial design didn't make any distinction between absent fields and fields containing undef. Maybe it was not the best choice, but I will not change this basic design, because that would be an abrupt change of behaviour, creating problems for other clients (including my own employer).
I hope you can attain your goals using the new attribute '-may_ignore'.
Otherwise your other option is to create your own subclass of Data::Domain where you implement the behaviour you need.

damil commented

v1.13 is on CPAN