Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib/types: add record (simplified submodule) #334680

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions lib/tests/modules.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,43 @@ checkConfigError() {
fi
}

# record field
checkConfigOutput '^"Alice"$' config.people.alice.name ./declare-record.nix ./define-record-alice.nix ./define-record-bob.nix
checkConfigOutput '^2019$' config.people.bob.nixerSince ./declare-record.nix ./define-record-alice.nix ./define-record-bob.nix

# record field type error
checkConfigError 'A definition for option .people.mallory.nixerSince. is not of type .signed integer.. Definition values' config.people.mallory.nixerSince ./declare-record.nix ./define-record-mallory.nix
checkConfigError 'define-record-mallory.nix.: "beginning of time"' config.people.mallory.nixerSince ./declare-record.nix ./define-record-mallory.nix

# record field default
checkConfigOutput '^true$' config.people.bob.isCool ./declare-record.nix ./define-record-alice.nix ./define-record-bob.nix

# record field bad default definition
checkConfigError 'In .the default value of option people.mallory.: "yeah"' config.people.mallory.isCool ./declare-record-bad-default.nix ./define-record-mallory.nix
checkConfigError 'A definition for option .people.mallory.isCool. is not of type .boolean.. Definition values:' config.people.mallory.isCool ./declare-record-bad-default.nix ./define-record-mallory.nix

# record field works in presence of wildcard
checkConfigOutput '^2016$' config.people.alice.nixerSince ./declare-record-wildcard.nix ./define-record-alice-prefs.nix

# record wildcard field
checkConfigOutput '^true$' config.people.alice.mechKeyboard ./declare-record-wildcard.nix ./define-record-alice-prefs.nix

# record definition without corresponding field
checkConfigError 'A definition for option .people.mike. has an unknown field' config.people.mike.age ./declare-record.nix ./define-record-mike.nix
# record optional field without definition
checkConfigError "attribute 'age' in selection path 'config.people.alice.age' not found" config.people.alice.age ./declare-record-optional-field.nix ./define-record-alice.nix
# record optional field with definition
checkConfigOutput '^27$' config.people.mike.age ./declare-record-optional-field.nix ./define-record-mike.nix

#TODO:
# - test empty definitions
# - test neseted records
# - test nested optional records
# - etc?


if false; then

# Shorthand meta attribute does not duplicate the config
checkConfigOutput '^"one two"$' config.result ./shorthand-meta.nix

Expand Down Expand Up @@ -558,6 +595,8 @@ checkConfigOutput '^34|23$' options.submoduleLine34.declarationPositions.1.line
# nested options work
checkConfigOutput '^30$' options.nested.nestedLine30.declarationPositions.0.line ./declaration-positions.nix

fi

cat <<EOF
====== module tests ======
$pass Pass
Expand Down
20 changes: 20 additions & 0 deletions lib/tests/modules/declare-record-bad-default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{ lib, ... }:

let
inherit (lib) mkOption types;

person = types.record {
fields = {
nixerSince = mkOption { type = types.int; };
name = mkOption { type = types.str; };
isCool = mkOption {
type = types.bool;
default = "yeah";
};
};
};

in
{
options.people = mkOption { type = types.attrsOf person; };
}
20 changes: 20 additions & 0 deletions lib/tests/modules/declare-record-optional-field.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{ lib, ... }:

let
inherit (lib) mkOption types;

person = types.record {
fields = {
nixerSince = mkOption { type = types.int; };
name = mkOption { type = types.str; };
};
optionalFields = {
age = mkOption { type = types.ints.unsigned; };
};
wildcard = mkOption { type = types.bool; };
};

in
{
options.people = mkOption { type = types.attrsOf person; };
}
17 changes: 17 additions & 0 deletions lib/tests/modules/declare-record-wildcard.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{ lib, ... }:

let
inherit (lib) mkOption types;

person = types.record {
fields = {
nixerSince = mkOption { type = types.int; };
name = mkOption { type = types.str; };
};
wildcard = mkOption { type = types.bool; };
};

in
{
options.people = mkOption { type = types.attrsOf person; };
}
20 changes: 20 additions & 0 deletions lib/tests/modules/declare-record.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{ lib, ... }:

let
inherit (lib) mkOption types;

person = types.record {
fields = {
nixerSince = mkOption { type = types.int; };
name = mkOption { type = types.str; };
isCool = mkOption {
type = types.bool;
default = true;
};
};
};

in
{
options.people = mkOption { type = types.attrsOf person; };
}
10 changes: 10 additions & 0 deletions lib/tests/modules/define-record-alice-prefs.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{ lib, ... }:
{
people.alice = {
nixerSince = 2016;
name = "Alice";
hiRes = true;
mechKeyboard = true;
spiders = false;
};
}
6 changes: 6 additions & 0 deletions lib/tests/modules/define-record-alice.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
people.alice = {
nixerSince = 2016;
name = "Alice";
};
}
6 changes: 6 additions & 0 deletions lib/tests/modules/define-record-bob.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
people.bob = {
nixerSince = 2019;
name = "Bob";
};
}
6 changes: 6 additions & 0 deletions lib/tests/modules/define-record-mallory.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
people.mallory = {
nixerSince = "beginning of time";
name = "Bobby";
};
}
7 changes: 7 additions & 0 deletions lib/tests/modules/define-record-mike.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
people.mike = {
nixerSince = 2020;
name = "Mike";
age = 27;
};
}
3 changes: 2 additions & 1 deletion lib/types.nix
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,8 @@ rec {
# Augment the given type with an additional type check function.
addCheck = elemType: check: elemType // { check = x: elemType.check x && check x; };

};
}
// import ./types/record.nix { inherit lib; };
};

in outer_types // outer_types.types
124 changes: 124 additions & 0 deletions lib/types/record.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
{ lib }:

let
inherit (lib)
mapAttrs
zipAttrsWith
removeAttrs
attrNames
showOption
optional
mergeDefinitions
mkOptionType
isAttrs
;

record =
{
fields ? { },
optionalFields ? { },
wildcard ? null,
}@args:
# TODO: assert that at least one arg is provided, i.e. args != { }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# TODO: assert that at least one arg is provided, i.e. args != { }

Probably not worth checking. record { fields = { }; } isn't better than record { }.
Both are basically enum [ { } ], and that's fine.

let
checkFields = mapAttrs (
name: value:
if value._type or null != "option" then
throw "Record field `${lib.escapeNixIdentifier name}` must be declared with `mkOption`."
else if value ? apply then
throw "In field ${lib.escapeNixIdentifier name} records do not support options with `apply`"
else if value ? readOnly then
throw "In field ${lib.escapeNixIdentifier name} records do not support options with `readOnly`"
else
value
);
fields' = checkFields fields;
# TODO: should we also check optionalFields have no default?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I was wrong to use mkOption.
It means that we have these extra checkFields checks to cover known problems, but it also couples record and module, holding back both types.
It seems that all of the reuse we do get is from option types, not options themselves, or we wouldn't be peeling out type and default ourselves, below.
And we don't even want defaults in the optional fields.
A mkField would be simpler, more efficient, and independent of modules. It seems that we only need type and default, but internal/visible and meta could be added.

Copy link
Contributor Author

@MattSturgeon MattSturgeon Oct 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree a mkField would make sense to further de-couple records from modules. This would also allow fields to declare whether they are optional, rather than having a separate optionalFields argument.

optionalFields' = checkFields optionalFields;
wildcard =
if args.wildcard or null == null then
null
else if args.wildcard._type or null != "option" then
throw "Record wildcard must be declared with `mkOption`."
else if args.wildcard ? apply then
throw "Records do not support options with `apply`, but wildcard has `apply`."
else
args.wildcard;
in
mkOptionType {
name = "record";
description = if wildcard == null then "record" else "open record of ${wildcard.description}";
descriptionClass = if wildcard == null then "noun" else "composite";
check = isAttrs;
merge =
loc: defs:
let
data = zipAttrsWith (name: values: values) (
map (
def:
mapAttrs (_fieldName: fieldValue: {
inherit (def) file;
value = fieldValue;
}) def.value
) defs
);
requiredFieldValues = mapAttrs (
fieldName: fieldOption:
builtins.addErrorContext "while evaluating the field `${fieldName}' of option `${showOption loc}'" (
(mergeDefinitions (loc ++ [ fieldName ]) fieldOption.type (
data.${fieldName} or [ ]
# FIXME: isn't this handled by `mergeDefinitions` already?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# FIXME: isn't this handled by `mergeDefinitions` already?

No, because we aren't passing the whole option.

What about the default's priority though? This looks like it gets added as a regular definition; not a mkOptionDefault that gets replaced entirely by a regular definition.

++ optional (fieldOption ? default) {
value = fieldOption.default;
file = "the default value of option ${showOption loc}";
}
)).mergedValue
)
) fields';
optionalFieldValues = lib.concatMapAttrs (
fieldName: fieldOption:
builtins.addErrorContext
"while evaluating the optional field `${fieldName}' of option `${showOption loc}'"
(
let
opt = mergeDefinitions (loc ++ [ fieldName ]) fieldOption.type (
data.${fieldName} or [ ]
# TODO: surely defaults make no sense for optional fields?
# And doesn't mergeDefinitions do this internally, anyway?
Comment on lines +86 to +87
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a weird one. It could work like the type emptyValue, and work like lazyAttrsOf here - a trade-off between omitting the attribute entirely, as one may expect, or avoiding infinite recursions, as one may expect.

attrsOf behavior: omit attribute when value is mkIf false. Infinite recursion for defs like settings = mkIf cfg.settings.foo { bar = "bar"; }

lazyAttrsOf behavior: keep the attribute even if mkIf false, but insert an empty value, or throw.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like we need a lazy and non-lazy mode for record types, since its users may wish for either behaviour.

In nixvim, for instance, we probably want the non-lazy behaviour where optional fields are not present in the resulting attrs.

This is because we usually want to be able to recursively map the attrs into a lua table using our version of lib.generators.toLua.

I guess we could have our toLua generator wrap recursion in tryEval and not render anything that throws, but that seems like an overly large hammer for this issue.

++ optional (fieldOption ? default) {
value = fieldOption.default;
file = "the default value of option ${showOption loc}";
}
);
in
lib.optionalAttrs opt.isDefined { ${fieldName} = opt.mergedValue; }
)
) optionalFields';
extraData = removeAttrs data (attrNames fields' ++ attrNames optionalFields');
in
if wildcard == null then
if extraData == { } then
requiredFieldValues
else
throw "A definition for option `${showOption loc}' has an unknown field."
else
requiredFieldValues
// optionalFieldValues
// mapAttrs (
fieldName: fieldDefs:
builtins.addErrorContext
"while evaluating the wildcard field `${fieldName}' of option `${showOption loc}'"
((mergeDefinitions (loc ++ [ fieldName ]) wildcard.type fieldDefs).mergedValue)
) extraData;
nestedTypes = fields' // {
# potential collision with `_wildcard` field
# TODO: should we prevent fields from having a wildcard?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# TODO: should we prevent fields from having a wildcard?
# TODO: should we prevent `fields` from having a `_wildcard`?

Seems easy and cheap enough, especially if we assert it here in the nestedTypes value, which I believe is only accessed when doing type merging, which is somewhat rare. (I'd have to check)

_wildcard = wildcard;
};
};

in
# public
{
inherit record;
}
43 changes: 43 additions & 0 deletions nixos/doc/manual/development/freeform-modules.section.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Freeform modules {#sec-freeform-modules}

<!-- TODO: Consider re-writing this doc to favor `types.record` over `types.submodule` -->

Freeform modules allow you to define values for option paths that have
not been declared explicitly. This can be used to add attribute-specific
types to what would otherwise have to be `attrsOf` options in order to
Expand All @@ -12,6 +14,47 @@ into the resulting `config` set. Since this feature nullifies name
checking for entire option trees, it is only recommended for use in
submodules.

<!-- TODO: Link to more details on the record and submodule types -->

Wildcard records can also be used to achieve the same thing, and may be
preferred in many scenarios due to their improved performance when
compared to submodules and also the ability to declare "optional" fields.

Wildcards records are used by simply passing `wildcard = types.anything`
to a record type definition.

::: {#ex-wildcard-record .example}
### Wildcard record

Most freeform submodules can also be represented as wildcard records.

The following example is equivalent to the first submodule example:

```nix
{ lib, config, ... }: {

options.settings = lib.mkOption {
type = lib.types.record {

wildcard = lib.types.str;

# We want this attribute to be checked for the correct type
fields.port = lib.mkOption {
type = lib.types.port;
# Declaring the option also allows defining a default value
default = 8080;
};

# We could use an "optional" field, instead of supplying a default
optionalFields.port = lib.mkOption {
type = lib.types.port;
};

};
};
}
```

::: {#ex-freeform-module .example}
### Freeform submodule

Expand Down
Loading
Loading