1
\$\begingroup\$

The Problem

I'm relying on plaintext entry to capture dates.

End users are urged to enter as MM-DD-YY, but it'll be great to account for as many possibilities.

US Date Format is assumed

I.e. MM-DD-YYYY, M-D-YYYY,M-D-YY, MM/DD/YYYY, MM/DD/YY, etc..

Current Code

//'1 or 2 digits...and then a single hyphen or forward slash..(REPEAT LAST PATTERN) followed by 2 or 4 digits
//MOD named group stands for 'Month or Day'

/(?<MOD>\d{1,2}[-\/])(?&MOD)(?:\d{4}|\d{2})/ 

You can run this with some unit tests I created too on https://regex101.com/r/LzoqRa/4

Questions

  1. Anything wrong with regex?
  2. Any way to simplify regex?
  3. Any other edge cases I should be thinking about?
  4. Is it worth it to capture MOD and recurse? Or should I just copy the pattern?
  5. What's the difference between regex match and recurse (relating to doing this on a previous pattern)?
\$\endgroup\$
6
  • 2
    \$\begingroup\$ Per the regex tag Usage guidance: "...Please include a tag specifying the programming language you are using, together with this tag." \$\endgroup\$ Feb 17, 2022 at 17:16
  • 1
    \$\begingroup\$ You don't mention your target audience, but from the formats you mention it seems to be solely aimed at the US market. Most of the world uses DMY or YMD, not MDY. See: Date format by country. A date like "3-4-22" could mean 3th of April 2022 or 4th of March 2022. Without knowing which format someone uses it is ambiguous. If you have any plans to use this code outside of the US I strongly recommend to ask for day, month and year separately or use a nice date picker. \$\endgroup\$ Feb 17, 2022 at 20:01
  • 1
    \$\begingroup\$ ^ sounds like a review @KIKO \$\endgroup\$ Feb 17, 2022 at 21:36
  • \$\begingroup\$ @mickmackusa Yes, it kind of is, isn't it? I just don't like to toy with regular expressions, they're too opaque for my taste (though even I use them sometimes). \$\endgroup\$ Feb 17, 2022 at 22:54
  • \$\begingroup\$ Totally agree. Regex is inappropriate for date validation. (currently editing my answer) \$\endgroup\$ Feb 17, 2022 at 22:55

1 Answer 1

2
\$\begingroup\$

Your ~(?<MOD>\d{1,2}[-/])(?&MOD)(?:\d{4}|\d{2})~ pattern takes 204 steps on your sample string.

The equivalent ~(?:\d{1,2}[-/]){2}(?:\d{4}|\d{2})~ pattern takes 188steps on your sample string.

The step count is a loose metric available to help you understand the efficiency of your pattern. The step count indicates that the recursion is doing more work than necessary.

But consider the string 5/6-1999, neither of the above patterns are preventing mixed delimiters.

Using a capture group on the first delimiter and then matching that specific delimiting character as the second delimiter seems critical if you are not going to pre-sanitize the input string. ~\d{1,2}([-/])\d{1,2}\1(?:\d{4}|\d{2})~ This further reduces the step count to 161, so it not only improves accuracy, it improves efficiency, but at a trivial cost to pattern brevity.

What @KIKOSoftware mentions as a comment under the question is also important. Your list of valid formats is not world-ready.


So, what is your goal here? If you are trying to validate a date looking "kinda" like a date, regex is fine. If you are actually trying to validate a date as a real date, then regex is certainly the wrong tool for the job.

To validate the string, use a loose pattern to isolate the components of your string, then feed them to the datetime class for parsing. If the string is parsed as a valid date that matches the sanitized input, then it is a good date. (Demo)

function isValidDate(string $date, string $format = 'm/d/Y', string $yearPadding = '19'): bool {
    $date = preg_replace_callback(
        '~^(\d+)(\D)(\d+)\2(\d{2}|\d{4})$~',
        fn($m) => sprintf('%02d/%02d/%d', $m[1], $m[3], str_pad($m[4], 4, $yearPadding, STR_PAD_LEFT)),
        $date
    );  // sanitize
    $d = DateTime::createFromFormat($format, $date);  // attempt to parse
    return $d && $d->format($format) === $date;  // check if formatted string is same as sanitized string
}

To validate the string and return it if it is deemed to be valid, you can modify the above like this: (Demo)

function getFormattedDate(string $date, string $format = 'm/d/Y', string $yearPadding = '19'): string {
    $sanitizedDate = preg_replace_callback(
        '~^(\d+)(\D)(\d+)\2(\d{2}|\d{4})$~',
        fn($m) => sprintf('%02d/%02d/%d', $m[1], $m[3], str_pad($m[4], 4, $yearPadding, STR_PAD_LEFT)),
        $date
    );
    $d = DateTime::createFromFormat($format, $sanitizedDate);
    if (!$d) {
        throw new Exception("Could not parse date: $date");
    }
    $formattedDate = $d->format($format);
    if ($formattedDate !== $sanitizedDate) {
        throw new Exception("Date not in a desired American format: $date");
    }
    return $date;
}

foreach ($tests as $test) {
    try {
        echo getFormattedDate($test);
    } catch (Exception $e) {
        echo $e->getMessage();
    }
    echo "\n";
}
\$\endgroup\$
2
  • 1
    \$\begingroup\$ Yes, I like that function. You could use it to test for several formats, like isValidDate($date, 'm/d/Y') or isValidDate(string $date, 'm/d/y'). Perhaps the function would be more useful if it returned a DateTime object when the date is in that format, and null when it isn't. Saves the trouble of trying to get it into an usable format later on. \$\endgroup\$ Feb 17, 2022 at 23:11
  • \$\begingroup\$ @mickmackusa Wow, once again, went above-and-beyond. I actually had something similar to your getFormattedDate fn..I just left it outside this question, so thanks again for doing more than asked for \$\endgroup\$ Feb 18, 2022 at 16:06

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.