Skip to content

Commit 690a7a5

Browse files
authored
Merge pull request #825 from smucclaw/thomasgorissen/jsondecode-date-support
Add DATE type support for JSONDECODE
2 parents 323f608 + 7019aa0 commit 690a7a5

File tree

11 files changed

+518
-66
lines changed

11 files changed

+518
-66
lines changed

jl4-core/src/L4/EvaluateLazy/Machine.hs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,6 +1284,16 @@ jsonValueToWHNFTyped jsonValue ty = do
12841284
Aeson.Bool b -> pure $ if b then ValBool True else ValBool False
12851285
_ -> InternalException $ RuntimeTypeError $
12861286
"Expected JSON boolean but got: " <> Text.pack (show jsonValue)
1287+
"DATE" -> do
1288+
-- DATE fields in JSON should be ISO-8601 strings (YYYY-MM-DD)
1289+
case jsonValue of
1290+
Aeson.String s -> do
1291+
case parseDateText s of
1292+
Just day -> pure $ ValDate day
1293+
Nothing -> InternalException $ RuntimeTypeError $
1294+
"Could not parse date string '" <> s <> "'. Expected format: YYYY-MM-DD"
1295+
_ -> InternalException $ RuntimeTypeError $
1296+
"Expected JSON string for DATE field but got: " <> Text.pack (show jsonValue)
12871297

12881298
-- Not a primitive, check if it's a custom record type
12891299
_ -> do

jl4-decision-service/README.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ This is useful for building conversational interfaces that ask only relevant que
200200

201201
### State Graphs
202202

203-
L4 regulative rules (using `MUST`, `MAY`, `SHANT`) define contract state machines. The decision service can extract and visualize these:
203+
L4 regulative rules (using `MUST`, `MAY`, `SHANT`, `DO`) define contract state machines. The decision service can extract and visualize these:
204204

205205
- `GET /functions/<name>/state-graphs`: List all state graphs in the module
206206
- `GET /functions/<name>/state-graphs/<graphName>`: Get DOT source for a specific graph
@@ -219,15 +219,16 @@ curl -s 'http://localhost:8081/functions/weddingcontract/state-graphs/weddingcer
219219
```
220220

221221
State graphs show:
222+
222223
- **States**: Initial, intermediate, fulfilled, and breach states
223224
- **Transitions**: Labeled with the triggering action and any temporal constraints (WITHIN deadlines)
224225
- **Deontic modality**: Whether transitions are obligations (MUST), permissions (MAY), or prohibitions (SHANT)
225226

226227
## Loading L4 Functions
227228

228-
Three functions are hardcoded by default (`compute_qualifies`, `vermin_and_rodent`, `the_answer`); see [src/Examples.hs](src/Examples.hs) for details.
229+
When **no** `--sourcePaths` are provided, the service loads three hardcoded example functions (`compute_qualifies`, `vermin_and_rodent`, `the_answer`) for testing purposes; see [src/Examples.hs](src/Examples.hs) for details.
229230

230-
Other functions can be loaded at start time using the `--sourcePaths` command line option.
231+
When `--sourcePaths` are provided, **only** functions from those files are loaded—the hardcoded examples are not included.
231232

232233
The argument to the option is a directory or individual `.l4` files. The service automatically follows `IMPORT` statements to load dependencies.
233234

@@ -303,15 +304,15 @@ function:
303304
304305
## CLI Options
305306
306-
| Option | Description | Default |
307-
|--------|-------------|---------|
308-
| `--port`, `-p` | HTTP port | 8081 |
309-
| `--serverName`, `-s` | Server URL (for swagger.json) | - |
310-
| `--sourcePaths`, `-f` | L4 files or directories to load | - |
311-
| `--crudServerName` | Session backend hostname | localhost |
312-
| `--crudServerPort` | Session backend port | 5008 |
313-
| `--crudServerSecure` | Use HTTPS for session backend | false |
314-
| `--crudServerPath` | Path prefix for session backend | (empty) |
307+
| Option | Description | Default |
308+
| --------------------- | ------------------------------- | --------- |
309+
| `--port`, `-p` | HTTP port | 8081 |
310+
| `--serverName`, `-s` | Server URL (for swagger.json) | - |
311+
| `--sourcePaths`, `-f` | L4 files or directories to load | - |
312+
| `--crudServerName` | Session backend hostname | localhost |
313+
| `--crudServerPort` | Session backend port | 5008 |
314+
| `--crudServerSecure` | Use HTTPS for session backend | false |
315+
| `--crudServerPath` | Path prefix for session backend | (empty) |
315316

316317
### Session Backend Integration
317318

jl4-decision-service/src/Application.hs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,18 @@ defaultMain = do
4949
when (null sourcePaths) $ putStrLn $ "sourcePaths expanded to empty: " <> show sourcePaths
5050
unless (null sourcePaths) $ putStrLn $ "Scanning .l4 files from: " <> show l4Files
5151

52-
(l4Functions, _moduleContext) <- Examples.loadL4Functions l4Files
53-
unless (null sourcePaths) $ putStrLn $ "** Loaded l4 functions from disk: " <> show (length l4Functions)
54-
unless (null l4Functions) $ print $ Map.keys l4Functions
55-
56-
exampleFunctions <- Examples.functionSpecs
57-
dbRef <- newTVarIO (exampleFunctions <> l4Functions)
52+
-- Only load hardcoded examples if no source paths provided
53+
-- When source paths are given, only load functions from those files
54+
functions <- if null sourcePaths
55+
then do
56+
putStrLn "* No source paths provided, loading hardcoded examples"
57+
Examples.functionSpecs
58+
else do
59+
(l4Functions, _moduleContext) <- Examples.loadL4Functions l4Files
60+
putStrLn $ "** Loaded l4 functions from disk: " <> show (length l4Functions)
61+
unless (null l4Functions) $ print $ Map.keys l4Functions
62+
pure l4Functions
63+
dbRef <- newTVarIO functions
5864
mgr <- newManager defaultManagerSettings
5965
putStrLn $ "will contact crud server on following base url: " <> show crudServerName
6066
let

jl4-decision-service/src/Backend/CodeGen.hs

Lines changed: 102 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,43 @@ import qualified Data.Text as Text
1010
import qualified Data.Text.Lazy as TL
1111
import qualified Data.Text.Lazy.Encoding as TL
1212
import L4.Syntax (Type'(..), Resolved, getUnique)
13-
import L4.TypeCheck.Environment (booleanUnique)
13+
import L4.TypeCheck.Environment (booleanUnique, dateUnique)
1414
import Backend.Api (TraceLevel(..))
1515
import Backend.MaybeLift (liftTypeToMaybe)
1616

17+
-- | Check if a type is exactly DATE (not MAYBE DATE, LIST OF DATE, etc.)
18+
-- Used to determine if we need TODATE conversion from JSON strings
19+
isDateType :: Type' Resolved -> Bool
20+
isDateType (TyApp _ name []) = getUnique name == dateUnique
21+
isDateType _ = False
22+
23+
-- | Check if a type contains DATE at its core (unwrapping MAYBE)
24+
-- e.g., DATE -> True, MAYBE DATE -> True, MAYBE (MAYBE DATE) -> True
25+
-- but LIST OF DATE -> False (we don't handle nested structures yet)
26+
containsDateType :: Type' Resolved -> Bool
27+
containsDateType ty = isDateType (unwrapMaybe ty)
28+
where
29+
-- Unwrap MAYBE layers to get to the core type
30+
-- Note: We can't easily check for maybeUnique here without importing it,
31+
-- so we use a heuristic: if it's a TyApp with one type argument, check inner
32+
unwrapMaybe :: Type' Resolved -> Type' Resolved
33+
unwrapMaybe (TyApp _ _ [inner]) = unwrapMaybe inner
34+
unwrapMaybe t = t
35+
36+
-- | Quote an identifier with backticks if it contains spaces or special characters
37+
-- L4 requires backticks for identifiers with spaces
38+
quoteIdent :: Text -> Text
39+
quoteIdent name
40+
| Text.any (== ' ') name = "`" <> name <> "`"
41+
| otherwise = name
42+
43+
-- | Create a safe unwrapped variable name
44+
-- For names with spaces, creates a backtick-quoted identifier like `unwrapped_tranche number`
45+
unwrappedVar :: Text -> Text
46+
unwrappedVar name
47+
| Text.any (== ' ') name = "`unwrapped_" <> name <> "`"
48+
| otherwise = "unwrapped_" <> name
49+
1750
-- | Result of code generation
1851
data GeneratedCode = GeneratedCode
1952
{ generatedWrapper :: Text
@@ -62,13 +95,13 @@ generateEvalWrapper funName givenParams assumeParams inputJson traceLevel = do
6295
isBooleanType :: Type' Resolved -> Bool
6396
isBooleanType (TyApp _ name []) = getUnique name == booleanUnique
6497
isBooleanType _ = False
65-
-- Annotate GIVEN params with their boolean status and mark as GIVEN
66-
givenParamInfo = map (\(name, ty) -> ((name, ty), isBooleanType ty, True)) givenParams
67-
-- Annotate ASSUME params with their boolean status and mark as ASSUME
68-
assumeParamInfo = map (\(name, ty) -> ((name, ty), isBooleanType ty, False)) assumeParams
98+
-- Annotate GIVEN params with (type info, isBoolean, isGiven, isDate)
99+
givenParamInfo = map (\(name, ty) -> ((name, ty), isBooleanType ty, True, containsDateType ty)) givenParams
100+
-- Annotate ASSUME params with (type info, isBoolean, isGiven, isDate)
101+
assumeParamInfo = map (\(name, ty) -> ((name, ty), isBooleanType ty, False, containsDateType ty)) assumeParams
69102
allParamInfo = givenParamInfo <> assumeParamInfo
70103
-- We always need prelude for fromMaybe (all booleans use it)
71-
hasBooleans = any (\(_, isB, _) -> isB) allParamInfo
104+
hasBooleans = any (\(_, isB, _, _) -> isB) allParamInfo
72105
in Right GeneratedCode
73106
{ generatedWrapper = Text.unlines $
74107
[ ""
@@ -106,7 +139,9 @@ generateInputRecordLifted params = Text.unlines $
106139
let indent = if idx == 0 then " " else ", "
107140
-- Lift ALL types to MAYBE
108141
tyText = liftTypeToMaybe ty
109-
in indent <> name <> " IS A " <> tyText
142+
-- Quote identifiers with spaces
143+
quotedName = quoteIdent name
144+
in indent <> quotedName <> " IS A " <> tyText
110145

111146
-- | Generate typed decoder function
112147
generateDecoder :: Text
@@ -134,11 +169,11 @@ escapeAsL4String val =
134169
-- GIVEN params are passed as function arguments.
135170
-- ASSUME params are injected as LET bindings before the function call.
136171
--
137-
-- Type signature: [((name, type), isBoolean, isGiven)]
172+
-- Type signature: [((name, type), isBoolean, isGiven, isDate)]
138173
generateEvalDirectiveLiftedWithAssumes
139174
:: Text
140-
-> [((Text, Type' Resolved), Bool, Bool)] -- ^ GIVEN params: ((name, type), isBoolean, isGiven=True)
141-
-> [((Text, Type' Resolved), Bool, Bool)] -- ^ ASSUME params: ((name, type), isBoolean, isGiven=False)
175+
-> [((Text, Type' Resolved), Bool, Bool, Bool)] -- ^ GIVEN params: ((name, type), isBoolean, isGiven=True, isDate)
176+
-> [((Text, Type' Resolved), Bool, Bool, Bool)] -- ^ ASSUME params: ((name, type), isBoolean, isGiven=False, isDate)
142177
-> TraceLevel
143178
-> Text
144179
generateEvalDirectiveLiftedWithAssumes funName givenParamInfo assumeParamInfo traceLevel =
@@ -150,33 +185,38 @@ generateEvalDirectiveLiftedWithAssumes funName givenParamInfo assumeParamInfo tr
150185
allParams = givenParamInfo <> assumeParamInfo
151186

152187
-- Separate boolean and non-boolean params (for unwrapping logic)
153-
nonBoolAllParams = filter (\(_, isB, _) -> not isB) allParams
188+
nonBoolAllParams = filter (\(_, isB, _, _) -> not isB) allParams
154189

155190
-- Generate argument expressions for GIVEN params only (in original order)
156191
-- Booleans use fromMaybe FALSE directly
157192
-- Non-booleans reference unwrapped variable names
193+
-- Date types need conversion via dateConvertedVar
158194
givenArgExprs = map snd $ sortOn fst $
159-
[(idx, expr) | (idx, ((name, _), True, _)) <- zip [0 :: Int ..] givenParamInfo
160-
, let expr = "(fromMaybe FALSE (args's " <> name <> "))"] ++
161-
[(idx, expr) | (idx, ((name, _), False, _)) <- zip [0 :: Int ..] givenParamInfo
162-
, let expr = "unwrapped_" <> name]
195+
[(idx, expr) | (idx, ((name, _), True, _, _)) <- zip [0 :: Int ..] givenParamInfo
196+
, let expr = "(fromMaybe FALSE (args's " <> quoteIdent name <> "))"] ++
197+
[(idx, expr) | (idx, ((name, _), False, _, isDate)) <- zip [0 :: Int ..] givenParamInfo
198+
, let expr = if isDate then dateConvertedVar name else unwrappedVar name]
163199

164200
-- Build function call with only GIVEN params as arguments
201+
-- Quote function name if it contains spaces
202+
quotedFunName = quoteIdent funName
165203
functionCall = if null givenArgExprs
166-
then funName
167-
else funName <> " " <> Text.unwords givenArgExprs
204+
then quotedFunName
205+
else quotedFunName <> " " <> Text.unwords givenArgExprs
168206

169207
-- Wrap function call with LET bindings for ASSUME params
170208
-- Boolean ASSUMEs use fromMaybe FALSE
171209
-- Non-boolean ASSUMEs use unwrapped_ variables
210+
-- Date ASSUMEs use dateConverted_ variables
172211
wrapWithAssumes :: Text -> Text
173212
wrapWithAssumes innerExpr = foldr wrapOne innerExpr assumeParamInfo
174213
where
175-
wrapOne ((name, _), isBoolean, _) expr =
176-
let valueExpr = if isBoolean
177-
then "(fromMaybe FALSE (args's " <> name <> "))"
178-
else "unwrapped_" <> name
179-
in "LET " <> name <> " = " <> valueExpr <> " IN " <> expr
214+
wrapOne ((name, _), isBoolean, _, isDate) expr =
215+
let quotedName = quoteIdent name
216+
valueExpr = if isBoolean
217+
then "(fromMaybe FALSE (args's " <> quotedName <> "))"
218+
else if isDate then dateConvertedVar name else unwrappedVar name
219+
in "LET " <> quotedName <> " = " <> valueExpr <> " IN " <> expr
180220

181221
-- The innermost expression (wrapped function call in JUST)
182222
innerCall = "JUST (" <> wrapWithAssumes functionCall <> ")"
@@ -201,29 +241,53 @@ generateEvalDirectiveLiftedWithAssumes funName givenParamInfo assumeParamInfo tr
201241

202242
-- | Generate nested CONSIDER for unwrapping non-boolean MAYBE values,
203243
-- with LET bindings for ASSUME params at the innermost level.
244+
-- For DATE types, adds an additional CONSIDER to convert string -> DATE via TODATE.
204245
generateNestedConsiderWithAssumes
205-
:: [((Text, Type' Resolved), Bool, Bool)] -- ^ Non-boolean params to unwrap
206-
-> Text -- ^ Function call (GIVEN args only)
207-
-> [((Text, Type' Resolved), Bool, Bool)] -- ^ ASSUME params for LET bindings
208-
-> Int -- ^ Indentation level
246+
:: [((Text, Type' Resolved), Bool, Bool, Bool)] -- ^ Non-boolean params to unwrap (name, type, isBoolean, isGiven, isDate)
247+
-> Text -- ^ Function call (GIVEN args only)
248+
-> [((Text, Type' Resolved), Bool, Bool, Bool)] -- ^ ASSUME params for LET bindings
249+
-> Int -- ^ Indentation level
209250
-> [Text]
210251
generateNestedConsiderWithAssumes [] functionCall assumeParamInfo indent =
211252
let indentStr = Text.replicate indent " "
212253
-- Wrap function call with LET bindings for ASSUME params
213254
wrapWithAssumes :: Text -> Text
214255
wrapWithAssumes innerExpr = foldr wrapOne innerExpr assumeParamInfo
215256
where
216-
wrapOne ((name, _), isBoolean, _) expr =
217-
let valueExpr = if isBoolean
218-
then "(fromMaybe FALSE (args's " <> name <> "))"
219-
else "unwrapped_" <> name
220-
in "LET " <> name <> " = " <> valueExpr <> " IN " <> expr
257+
wrapOne ((name, _), isBoolean, _, isDate) expr =
258+
let quotedName = quoteIdent name
259+
valueExpr = if isBoolean
260+
then "(fromMaybe FALSE (args's " <> quotedName <> "))"
261+
else if isDate then dateConvertedVar name else unwrappedVar name
262+
in "LET " <> quotedName <> " = " <> valueExpr <> " IN " <> expr
221263
in [indentStr <> "JUST (" <> wrapWithAssumes functionCall <> ")"]
222-
generateNestedConsiderWithAssumes (((name, _), _, _):rest) functionCall assumeParamInfo indent =
264+
generateNestedConsiderWithAssumes (((name, _), _, _, isDate):rest) functionCall assumeParamInfo indent =
223265
let indentStr = Text.replicate indent " "
224-
in [ indentStr <> "CONSIDER args's " <> name
225-
, indentStr <> " WHEN JUST unwrapped_" <> name <> " THEN"
226-
] ++
227-
generateNestedConsiderWithAssumes rest functionCall assumeParamInfo (indent + 2) ++
228-
[ indentStr <> " WHEN NOTHING THEN NOTHING"
229-
]
266+
quotedName = quoteIdent name
267+
in if isDate
268+
then
269+
-- For DATE types: first unwrap the MAYBE STRING, then convert to DATE via TODATE
270+
[ indentStr <> "CONSIDER args's " <> quotedName
271+
, indentStr <> " WHEN JUST " <> unwrappedVar name <> " THEN"
272+
, indentStr <> " CONSIDER TODATE " <> unwrappedVar name
273+
, indentStr <> " WHEN JUST " <> dateConvertedVar name <> " THEN"
274+
] ++
275+
generateNestedConsiderWithAssumes rest functionCall assumeParamInfo (indent + 4) ++
276+
[ indentStr <> " WHEN NOTHING THEN NOTHING -- date parse failed"
277+
, indentStr <> " WHEN NOTHING THEN NOTHING"
278+
]
279+
else
280+
-- For non-DATE types: just unwrap the MAYBE
281+
[ indentStr <> "CONSIDER args's " <> quotedName
282+
, indentStr <> " WHEN JUST " <> unwrappedVar name <> " THEN"
283+
] ++
284+
generateNestedConsiderWithAssumes rest functionCall assumeParamInfo (indent + 2) ++
285+
[ indentStr <> " WHEN NOTHING THEN NOTHING"
286+
]
287+
288+
-- | Create a variable name for date-converted values
289+
-- For names with spaces, creates a backtick-quoted identifier like `dateConverted_earn date`
290+
dateConvertedVar :: Text -> Text
291+
dateConvertedVar name
292+
| Text.any (== ' ') name = "`dateConverted_" <> name <> "`"
293+
| otherwise = "dateConverted_" <> name

0 commit comments

Comments
 (0)