Skip to content

Conversation

@djbe
Copy link
Contributor

@djbe djbe commented May 10, 2018

This allows users to refer to variables (and properties) using indirection.

Let's say for example we have the following context:

[
  "object": [
    "name": "John",
    "role": "Developer",
    "location": "Somewhere"
  ],
  "visible": ["name", "role"]
]

A user can then write:

{% for property in visible %}
 - {{property}}: {{ object.$property }}
{% endfor %}

To produce:

- name: John
- role: Developer

@djbe
Copy link
Contributor Author

djbe commented May 10, 2018

Note: the reason I went with the object.$property syntax instead of something like object[property] is that there's no cross-platform regex support (NSRegularExpression is macOS only).

current = normalize(current)

// try to evaluate bit if it's an indirection
var resolvedBit = bit
Copy link
Collaborator

Choose a reason for hiding this comment

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

you can do for var bit and mutate bit itself

var resolvedBit = bit
if bit.hasPrefix("$"),
let resolved = try? Variable(String(bit.dropFirst())).resolve(context),
let property = resolved {
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe better to do resolved as? String?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I had that first, but things break then if the resolved value is an Int for example (see the tests with array indirect lookup)

@AliSoftware
Copy link
Collaborator

AliSoftware commented May 10, 2018

I love the idea.

It's a shame that we can't use subscript for that though

  1. FWIW I think you could be able to use .[prop] instead of .$prop even without using regex, eg:
if bit.hasPrefix("[") && bit.hasSuffix("]"),
       let resolved = try? Variable(String(bit.dropFirst().dropLast())).resolve(context),
  1. but even with such a solution that would still mean the syntax need to use the dot, and would look like object.[property] where what we would ideally want would be object[property]. So in that regard if the solution needs for technical reasons to use a dot then I prefer o.$p to o.[p] and if we want to support o[p] we'd have to find a different solution for the implementation

  2. Before validating that kind of syntax I'd suggest to look at the syntax Jinja uses for such feature (if it does support it, that is). We have to remember that Stencil is inspired by Jinja-like template engines and it's better for the end user of we can use the same or very similar syntax and tags whenever possible/relevant for similar features.

@djbe
Copy link
Contributor Author

djbe commented May 10, 2018

@AliSoftware
Copy link
Collaborator

Damn so that would still be better to find a way to do that with braces without dot, both because it feels more natural and because it's similar to Jinja… but I guess you tried to find a solution for that already?

@djbe
Copy link
Contributor Author

djbe commented May 10, 2018

I'm honestly not sure how to tackle that without regular expressions, and without blowing this code up into some massive thing.

@djbe djbe force-pushed the feature/variable-indirection branch from 03cf1db to efc5ad3 Compare May 10, 2018 20:43
Copy link
Collaborator

@AliSoftware AliSoftware left a comment

Choose a reason for hiding this comment

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

The fact that this solution uses o.$p and not o[p] also means that p can't be a compound expression 😢

So we can't express object[property.name|lowercase] for example with the $ syntax (except by using an intermediate {% set %} but that's not as pretty then)

@ilyapuchka
Copy link
Collaborator

ilyapuchka commented May 10, 2018

I didn't test this code, but this might work:

  fileprivate func lookup() -> [String] {
    return variable.split(separator: ".")
        .flatMap({ $0.split(separator: "[", maxSplits: 1) })
        .map({ String($0).trim(character: "]") })
  }

This will though only work on single brackets, like foo[bar], but not foo[bar[abc]], which I'm not sure we should even support.

@AliSoftware good point about filter.
Simple alternative to all of that can be a filter like foo|attr:bar (or with any other maningful name)

@ilyapuchka
Copy link
Collaborator

ilyapuchka commented May 10, 2018

Can also try split(maxSplits: 2, whereSeparator isSeparator: { $0 == "[" || $0 == "]" }) variant where separator can be either [ or ] so there will be no need for trim, I guess.

@AliSoftware
Copy link
Collaborator

@ilyapuchka Mmmh interesting idea to use a filter to extract the property. But that would still mean we couldn't apply a filter on that filter 😅
it would enable us to do object|attr:property.name to do the equivalent of Jinja's object[property.name] but we still wouldn't be able to do the equivalent of Jinja's object[property.name|lowercase] for example

@AliSoftware
Copy link
Collaborator

I really think the right syntax is to use the same syntax as Jinja, the question is how to implement it in Stencil without breaking all the code…

@ilyapuchka
Copy link
Collaborator

@AliSoftware true. We can for that add support for brackets in filter expressions item|attr:(bar|attr:abc) which might be also useful in general. I have added that for boolean expressions in #165

@djbe
Copy link
Contributor Author

djbe commented May 10, 2018

I'm working on a solution right now, might take a bit. I'm going to iterate over the characters (in the lookup function), and keep track if I'm in a lookup (between [ and ]). I'll even support multiple lookup levels 😄

CHANGELOG.md Outdated
[Yonas Kolb](https://github.com/yonaskolb)
[#394](https://github.com/stencilproject/Stencil/pull/214)
[#214](https://github.com/stencilproject/Stencil/pull/214)
- Variables now support indirect evaluation. For example, if you have a variable `key = "name"`, and an
Copy link
Collaborator

@kylef kylef May 10, 2018

Choose a reason for hiding this comment

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

This would be a breaking change right? If so then it should go into ### Breaking section too.

For example, the following that works in Stencil 0.11 would no longer work?:

let schema = [
  "$schema": "http://json-schema.org/draft-06/schema#",
  "type": "object",
  "description" "A geographical coordinate",
  "properties": [
    "latitude": ["type": "number"],
    "longitude": ["type": "number"],
  ]
]
<h2>{{ schema.description|default:"Schema" }}</h2>
<a href="{{ schema.$schema }}">View Schema Specification</a>

<code>
  {{ schema|serialiseJSON }}
</code>

Perhaps an alternative would be to make this a filter itself instead of overloading special sub-syntax into this, then the same can be achieved by the following syntax which perhaps it a little clearer for the reader of the template:

{{ item|valueForKey:"key" }}

Copy link
Collaborator

Choose a reason for hiding this comment

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

We had a pr for that actually #191, but then I added support for single parameter for map filter in #189 , for which we can also define valueForKey synonym.

Copy link
Contributor Author

@djbe djbe May 10, 2018

Choose a reason for hiding this comment

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

Yeah, I'm rewriting it to use object[property] syntax like jinja.

@djbe djbe force-pushed the feature/variable-indirection branch from 258e876 to 3e6bbf2 Compare May 10, 2018 22:11
@djbe
Copy link
Contributor Author

djbe commented May 10, 2018

Since the force push is hiding the commit comments:

Does that mean that object[[[]]]...[]..[] would be valid, as you filter out empty bits?

We should define the syntax a bit more. To make things a bit easer:

  • should {{ [property] }} be valid? (see first test, it should IMO)
  • if it is, then {{ object[[[stuff]]] }} is valid
  • the .... was already valid in Stencil.

}

guard !partialBits.isEmpty else {
throw TemplateSyntaxError("Attempting to dereference empty object in variable '\(variable)'")
Copy link
Collaborator

Choose a reason for hiding this comment

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

What does this mean? Maybe we can say the same simpler? I guess this handles .. case? The we should just say that this is invalid syntax.

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's more than just .., it could be stuff.., or stuff..other.stuff, or other cases. It's already a TemplateSyntaxError, this just specifies what's wrong.

Copy link
Collaborator

Choose a reason for hiding this comment

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

If I understand code flow correctly here errors will all fall into unexpected '.' in \(variable) kind of description.

struct LookupParser {
private var bits = [String]()
private var current = ""
private var partialBits = [String]()
Copy link
Collaborator

Choose a reason for hiding this comment

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

What partialBits stands for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In a lookup of my[a.b].stuff[ref][ref2].here, the parser will go through every bit, which might contain bracket syntax. So in this case, the partialBits would end up being:

  • my[a.b]
  • stuff[ref][ref2]
  • here

It's called bit because the result is a bunch of bits (old name, from before this PR), and partial, because it's still parsing the bit. Naming things is hard, and it's not like stuffBeforeDot is better?


/// A structure used to represent a template variable, and to resolve it in a given context.
struct LookupParser {
private var bits = [String]()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe rename it to keyPath?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That'd be more a question for @kylef, what's the reason for the bits naming?

// when opening the first bracket, we must have a partial bit
private mutating func openBracket() throws {
guard !partialBits.isEmpty || !current.isEmpty else {
throw TemplateSyntaxError("Attempting to dereference an empty object in variable '\(variable)'")
Copy link
Collaborator

Choose a reason for hiding this comment

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

same as before, if this handles [[ syntax then let's just say that in error message

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same answer as before. [[ might be just one of the cases, we can't assume that.

Copy link
Collaborator

Choose a reason for hiding this comment

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

And here it can be probably unexpected '[' in '\(variable)'

import Foundation

/// A structure used to represent a template variable, and to resolve it in a given context.
struct LookupParser {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe VariableLookup will be a better name? I would say that's the main purpose of this type where parsing is secondary.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also probably this can be a class, so you can remove mutating attribute (which will also probably save from unnecessary memory allocations in runtime)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Naming things is hard. Maybe KeypathParser, using your keypath suggestion from before?
Input from others is welcome, so we can reach a consensus on this.

try context.push(dictionary: ["property": 5]) {
let variable = Variable("contacts[property]")
let result = try variable.resolve(context) as? String
try expect(result).to.beNil()
Copy link
Collaborator

Choose a reason for hiding this comment

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

it is probably unrelated to this PR, but I wonder if it would be better to raise runtime exceptions in such cases, I find it strange that Stencil just returns nils in such cases, it can be really hard to debug templates with such behaviour. I.e. I think EJS will raise exception in this case, but I don't know what is the common approach in other templating languages.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think django/jinja just return nils, like here. It's up to the developer to check for empty cases and handle these accordingly. How would you otherwise check if something exists or not, without triggering an exception?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see it like that. Lookup function should return nil if variable is not defined in the context, but if it is array subscript or type introspection it should throw. But even in the first case I don't think nil lookup result should be rendered as empty strings, it should be exception. In Jinja I believe one can check if variable is not nil using is defined ({% if loop.previtem is defined and value > loop.previtem %}). But I'm not very well familiar with Jinja and Django to advocate for that and it is not related to this PR so lets leave it for another time.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Quote from Jinja template docs:

If a variable or attribute does not exist, you will get back an undefined value. What you can do with that kind of value depends on the application configuration: the default behavior is to evaluate to an empty string if printed or iterated over, and to fail for every other operation.

So it returns an invalid value (doesn't throw), even for subscripts, that can be printed, iterated (and tested in ifs I assume), but other operations will fail (exception) for it.

}
}

$0.it("can resolve multiple indirections") {
Copy link
Collaborator

Choose a reason for hiding this comment

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

copy paste?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

derp

}
}

$0.it("can resolve multiple indirections") {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think in implementation you call it as references, I guess it should be used here too

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure if it should be called indirections, references, subscripts, ... 😕

Copy link
Collaborator

Choose a reason for hiding this comment

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

For those with Swift background the most clear name would be probably subscript.

}
}

$0.it("can resolve recursive indirections") {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nested references?

"article.author[prop]]",
"article.author[]",
"article.author[[]]",
"article.author[prop][]",
Copy link
Collaborator

Choose a reason for hiding this comment

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

also article.author[prop]comments (missing . after ])

@djbe djbe force-pushed the feature/variable-indirection branch from 8d76b79 to e395a10 Compare May 13, 2018 19:25
@djbe djbe changed the title Variable indirection Subscript syntax for Variables May 13, 2018
Copy link
Collaborator

@ilyapuchka ilyapuchka left a comment

Choose a reason for hiding this comment

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

I think this is awesome 👏

@ilyapuchka ilyapuchka merged commit 2e18892 into master May 19, 2018
@ilyapuchka ilyapuchka deleted the feature/variable-indirection branch May 19, 2018 20:03
@djbe djbe added this to the 0.12.0 milestone Jul 12, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants