Feel.Scope (feel v0.1.0)

Copy Markdown View Source

The names and types an expression may refer to, known before it runs.

Feel.prepare/2 uses a scope to resolve names, to match names that contain spaces or operator characters, and to check calls and infer types. A scope has these parts:

  • names - the variables, with the type of each value they will be bound to. A function is a variable whose type is a function type.
  • types - the type names an expression may use, such as the item definitions of a DMN model.
  • allowed_values - for some variables, the values they may have, as the source text of a list of unary tests, as in the allowedValues of a DMN item definition ("[0..150]", ""a", "b"", "< 100"). See below.
  • constraints - the allowed values and type constraints for variables and their members, with key steps through contexts and :each through lists (:type_constraints in new/2).
  • result_type and result_allowed_values - what the expression gives: the type of its result, and the values it may have. They are the typeRef and the allowedValues of the variable of a DMN decision.

Allowed values

The bound value of a variable with allowed values is verified when a run starts (D-138), for the variables that the expression reads: a value that none of the tests accepts is replaced by null, with a :value_not_allowed error, and null itself is exempt. The tests are tested against the value as ?, and may use the other names of the scope. They are read when an expression that uses the variable is prepared, and a test that cannot be read is an error of that expression, with the code :invalid_allowed_values. Their source is kept as it is written, since a scope is data; Feel.Analysis.AllowedValues compiles it.

A variable declared as a list ({:list, :number}) has the tests applied to each element, as the allowedValues of a DMN item definition with isCollection are (D-51): "[0..10]" for list<number> means that every element, other than null, is in [0..10]. The first element that is not makes the whole value null, with an error that says which. So are the allowed values of the result, when its result_type is a list (D-138).

Type constraints, and the members of a value

The typeConstraint of a DMN item definition is a list of unary tests like the allowed values, but for the value as a whole: for a list, "count(?) > 0" says the list has an element, where allowed values are for each element. They are given as type_constraints, by the name of the variable. For a value that is not a list the two are the same.

Both can be given for a member of a value, by a subject {name, steps}: the steps are the keys of the context to go down, and :each to go to the elements of a list. {"person", ["age"]} is the age of a person, {"orders", [:each, "total"]} the total of each order. The tests are for the member as the tests of a variable are for the variable, and a member that is not there or is null is exempt. A value with a member that is not allowed is not allowed, as a list with an element that is not (the whole value is null, with an error that says which member). They narrow the type of the member, so person.age is a number in [0..150].

Names that are never null

In the specification null is a value of every type, so a variable declared :number may be null, and so is anything computed from it. A type marked not null ({:non_null, :number}, or "number!" for Feel.Type.parse/2, D-48) says that the variable is never null: with the default declarations: :enforce the analysis types the expression without the null that the variable would bring, and a run ends with an error of the kind :input if the bound value is null, is not bound, cannot be converted to the type, or is not one of its allowed values. This is an extension to standard FEEL: expression type syntax also accepts the mark in function parameters and instance of targets. Preparation currently rejects an outer non-null result declaration with :not_implemented; result constraints are not a guarantee that evaluation cannot return null.

Types the lattice cannot say

The types of the specification have no union and no negation. A name that is anything but a number is {:not, :number} ("Any except number"), and one that is a number or a string is {:union, [:number, :string]} ("number | string"), for Feel.Type.parse/2 (D-178). They are declared like any other type, in the names, the :types, the :result_type and the :input, inside a list or a context, and under the mark: {:non_null, {:union, [:number, :string]}}. null is in them as it is in every declared type, unless the whole is marked. The type of the name in the analysis is the union they make, and with the default declarations: :enforce the run makes it true: a value that is not in it is null with a :type_mismatch error. See Feel.Type.Composite.

The result

The result of the expression is converted to the result_type and has to conform to it, and then it has to satisfy the result_allowed_values (D-138). In DMN this is how a decision gives its value to its variable. A scope is shared by the expressions that see the same names, so the scope of one expression is made with put_result/2 from the scope of the others, which is cheap. The options :result_type and :result_allowed_values of Feel.prepare/2 take the place of those of the scope.

The built-in functions are not stored here; they are always in scope, in the outermost layer, and a name in this scope shadows them.

All names in a scope are canonical (see Feel.Name). new/2 canonicalizes the names it is given. The functions that put and fetch single entries expect canonical names already, since the analyzer gets them from the parser.

Summary

Types

The allowed values (for each element of a list) or the type constraint (for the value as a whole).

A context key, :each for list elements, or a guarded declaration graph selector. The latter visits each occurrence of its target declaration from the root, retaining concrete runtime paths.

What is constrained: the variable itself (no steps), or a member of it.

t()

Functions

Returns the allowed values of the scope: a map from variable name to source text.

Everything that is constrained of a variable, whole or by member: the allowed values of the variable itself, its type constraint, and those of its members, each with the steps down to it, the kind and the source text. The name must be canonical.

Looks up the allowed values of a variable. The name must be canonical.

Looks up the type of a variable. The name must be canonical.

Looks up a type by name. The name must be canonical.

Merges two scopes. Entries of the second scope replace those of the first.

Returns the variable names in the scope, sorted.

Builds a scope from variable names and, optionally, type names and allowed values.

Sets the allowed values of a variable: the source text of a list of unary tests.

Adds or replaces a variable. The name must be canonical.

Marks the types of variables as not null (D-48): :all of them, or the ones in a list of canonical names.

Sets what the expression gives: the type of its result, and its allowed values.

Adds or replaces a type name. The name must be canonical.

Sets the type constraint of a variable or of a member of it: the source text of a list of unary tests for the value as a whole, which for a list is the list ("count(?) > 0") and not each element.

Returns the allowed values of the result, as source text, or nil when there are none.

Returns the type of the result of the expression, or nil when there is none.

Returns the type names in the scope, sorted.

Types

kind()

@type kind() :: :allowed_values | :type_constraint

The allowed values (for each element of a list) or the type constraint (for the value as a whole).

step()

@type step() ::
  Feel.Name.t()
  | :each
  | {:recursive, String.t(), String.t(), Feel.Type.Recursive.graph()}

A context key, :each for list elements, or a guarded declaration graph selector. The latter visits each occurrence of its target declaration from the root, retaining concrete runtime paths.

subject()

@type subject() :: Feel.Name.t() | {Feel.Name.t(), [step()]}

What is constrained: the variable itself (no steps), or a member of it.

t()

@type t() :: %Feel.Scope{
  allowed_values: %{optional(Feel.Name.t()) => String.t()},
  constraints: %{
    optional({Feel.Name.t(), [step()], kind()}) => [String.t(), ...]
  },
  names: %{optional(Feel.Name.t()) => Feel.Type.t()},
  result_allowed_values: String.t() | nil,
  result_type: Feel.Type.t() | nil,
  types: %{optional(Feel.Name.t()) => Feel.Type.t()}
}

Functions

allowed_values(scope)

@spec allowed_values(t()) :: %{optional(Feel.Name.t()) => String.t()}

Returns the allowed values of the scope: a map from variable name to source text.

constraints(scope, name)

@spec constraints(t(), Feel.Name.t()) :: [
  %{steps: [step()], kind: kind(), source: String.t(), index: non_neg_integer()}
]

Everything that is constrained of a variable, whole or by member: the allowed values of the variable itself, its type constraint, and those of its members, each with the steps down to it, the kind and the source text. The name must be canonical.

iex> scope = Feel.Scope.new(%{"p" => {:context, %{"age" => :number}}}, allowed_values: %{{"p", ["age"]} => "[0..150]"})
iex> Feel.Scope.constraints(scope, "p")
[%{steps: ["age"], kind: :allowed_values, source: "[0..150]", index: 0}]

fetch_allowed_values(scope, name)

@spec fetch_allowed_values(t(), Feel.Name.t()) :: {:ok, String.t()} | :error

Looks up the allowed values of a variable. The name must be canonical.

fetch_name(scope, name)

@spec fetch_name(t(), Feel.Name.t()) :: {:ok, Feel.Type.t()} | :error

Looks up the type of a variable. The name must be canonical.

fetch_type(scope, name)

@spec fetch_type(t(), Feel.Name.t()) :: {:ok, Feel.Type.t()} | :error

Looks up a type by name. The name must be canonical.

merge(left, right)

@spec merge(t(), t()) :: t()

Merges two scopes. Entries of the second scope replace those of the first.

names(scope)

@spec names(t()) :: [Feel.Name.t()]

Returns the variable names in the scope, sorted.

new(names \\ %{}, options \\ [])

@spec new(%{optional(String.t()) => Feel.Type.t()},
  types: %{optional(String.t()) => Feel.Type.t()},
  allowed_values: %{optional(subject()) => String.t() | [String.t()]},
  type_constraints: %{optional(subject()) => String.t() | [String.t()]},
  result_type: Feel.Type.t(),
  result_allowed_values: String.t(),
  not_null: :all | [String.t()]
) :: t()

Builds a scope from variable names and, optionally, type names and allowed values.

Options

  • :types - a map from type name to Feel.Type.t/0.
  • :allowed_values - a map from the name of a variable, or a subject {name, steps} (a member of it), to the source text of its allowed values, a list of unary tests, or a list of such texts, which all have to hold (a definition that has allowed values and refers to one that has). Every name must be a variable of the scope.
  • :type_constraints - the same for the type constraint, which is for the value as a whole.
  • :result_type - the type of the result of the expression, a Feel.Type.t/0.
  • :result_allowed_values - the allowed values of the result, as above.
  • :not_null - :all, or a list of names: the variables whose types are marked not null (put_not_null/2, D-48).

Names are canonicalized with Feel.Name.canonical/1. Raises ArgumentError for an allowed value that is not a string, or that is for a name that is not in the scope.

iex> scope = Feel.Scope.new(%{"age" => :number}, allowed_values: %{"age" => "[0..150]"})
iex> Feel.Scope.fetch_allowed_values(scope, "age")
{:ok, "[0..150]"}
iex> scope = Feel.Scope.new(%{"age" => :number, "name" => :string}, not_null: ["age"])
iex> {Feel.Scope.fetch_name(scope, "age"), Feel.Scope.fetch_name(scope, "name")}
{{:ok, {:non_null, :number}}, {:ok, :string}}

put_allowed_values(scope, subject, source)

@spec put_allowed_values(t(), subject(), String.t()) :: t()

Sets the allowed values of a variable: the source text of a list of unary tests.

The name must be canonical and a variable of the scope. Raises ArgumentError when it is not, or when the source is not a string.

iex> scope = Feel.Scope.put_name(%Feel.Scope{}, "age", :number)
iex> scope = Feel.Scope.put_allowed_values(scope, "age", "[0..150]")
iex> Feel.Scope.allowed_values(scope)
%{"age" => "[0..150]"}

put_name(scope, name, type)

@spec put_name(t(), Feel.Name.t(), Feel.Type.t()) :: t()

Adds or replaces a variable. The name must be canonical.

put_not_null(scope, names)

@spec put_not_null(t(), :all | [Feel.Name.t()]) :: t()

Marks the types of variables as not null (D-48): :all of them, or the ones in a list of canonical names.

A caller whose inputs are all required says so once, and not for each type. The mark is the one of a type written with !: with the default declarations: :enforce the analysis types an expression without the null that the variable would bring, and a run ends with an error of the kind :input if the bound value is null or is not bound. A type that is marked already stays as it is, and a variable of the type Null, which has no value but null, is left as it is. Raises ArgumentError for a name that is not a variable of the scope.

iex> scope = Feel.Scope.new(%{"age" => :number, "ages" => {:list, :number}})
iex> scope = Feel.Scope.put_not_null(scope, :all)
iex> {Feel.Scope.fetch_name(scope, "age"), Feel.Scope.fetch_name(scope, "ages")}
{{:ok, {:non_null, :number}}, {:ok, {:non_null, {:list, :number}}}}
iex> Feel.Scope.put_not_null(scope, ["other"])
** (ArgumentError) not_null for "other", which is not a name of the scope

put_result(scope, options)

@spec put_result(t(),
  result_type: Feel.Type.t() | nil,
  result_allowed_values: String.t() | nil
) :: t()

Sets what the expression gives: the type of its result, and its allowed values.

Options

  • :result_type - a Feel.Type.t/0. A type that names a type of the scope ({:named, "tAge"}) is resolved when the expression is prepared.
  • :result_allowed_values - the source text of a list of unary tests, as for a variable.

An option that is not given, or is nil, leaves what the scope has. Raises ArgumentError for allowed values that are not a string.

iex> scope = Feel.Scope.put_result(%Feel.Scope{}, result_type: :number, result_allowed_values: "[0..100]")
iex> {Feel.Scope.result_type(scope), Feel.Scope.result_allowed_values(scope)}
{:number, "[0..100]"}

put_type(scope, name, type)

@spec put_type(t(), Feel.Name.t(), Feel.Type.t()) :: t()

Adds or replaces a type name. The name must be canonical.

put_type_constraint(scope, subject, source)

@spec put_type_constraint(t(), subject(), String.t()) :: t()

Sets the type constraint of a variable or of a member of it: the source text of a list of unary tests for the value as a whole, which for a list is the list ("count(?) > 0") and not each element.

iex> scope = Feel.Scope.new(%{"xs" => {:list, :number}}, type_constraints: %{"xs" => "count(?) > 0"})
iex> Feel.Scope.constraints(scope, "xs")
[%{steps: [], kind: :type_constraint, source: "count(?) > 0", index: 0}]

result_allowed_values(scope)

@spec result_allowed_values(t()) :: String.t() | nil

Returns the allowed values of the result, as source text, or nil when there are none.

result_type(scope)

@spec result_type(t()) :: Feel.Type.t() | nil

Returns the type of the result of the expression, or nil when there is none.

type_names(scope)

@spec type_names(t()) :: [Feel.Name.t()]

Returns the type names in the scope, sorted.