Register a SA Forums Account here!
JOINING THE SA FORUMS WILL REMOVE THIS BIG AD, THE ANNOYING UNDERLINED ADS, AND STUPID INTERSTITIAL ADS!!!

You can: log in, read the tech support FAQ, or request your lost password. This dumb message (and those ads) will appear on every screen until you register! Get rid of this crap by registering your own SA Forums Account and joining roughly 150,000 Goons, for the one-time price of $9.95! We charge money because it costs us money per month for bills, and since we don't believe in showing ads to our users, we try to make the money back through forum registrations.
 
  • Locked thread
Pollyanna
Mar 5, 2005

Milk's on them.


Clojure is pretty drat cool. I'm learning it for the express purpose of wrapping my head around functional programming in general. I wanted to go through Functional Programming in Scala, but I felt like I didn't have the FP background for it. What are some good language-agnostic resources for the basics and theory behind FP?

Adbot
ADBOT LOVES YOU

Pollyanna
Mar 5, 2005

Milk's on them.


I've been meaning to get into Racket and Elixir more, too. Clojure's taking up most of my side-time, though. I'm considering making a basic lovely web app using Leiningen and Luminus, just to get used to the language. (It helps that Clojure can use Java packages.)

Elixir's next on my list.

Pollyanna
Mar 5, 2005

Milk's on them.


I'll mo your nad.

Pollyanna
Mar 5, 2005

Milk's on them.


Hass-kull.

Pollyanna
Mar 5, 2005

Milk's on them.


What is functional programming poor at or incapable of, really? You can't do classes and procedural stuff, but that's kind of by definition.

Pollyanna
Mar 5, 2005

Milk's on them.


What sort of techniques are good for handling data structures in FP? I've already noticed that things like recursion and tail-call optimization needs you to pass in something like an accumulator, your previous value, and you current value, but it's never quite sunk in.

Pollyanna
Mar 5, 2005

Milk's on them.


Might as well do it in a config file or something.

Pollyanna
Mar 5, 2005

Milk's on them.


The Lisp thread got archived or something, so I'm gonna guess that this is where we ask Clojure questions.

I've been trying to implement the card game War in Clojure. It's very simple: technically, there's two players, but there are basically no decisions made by either player so it can be programmed as a zero-player game. This boils the game down to an initial state, and a certain pipeline it goes through for each "tick" of the game. That means you can play the game by just pushing the state through the pipeline over and over, and rendering it to the screen when it comes out of the pipe.

I understand the logic of the game, and know what steps need to be taken, but the code I wrote for it is goddamn awful:

Lisp code:
;<excerpt>

(defn play-war-cards [game-state]
  (let [{:keys [player-1 player-2]} game-state
        new-player-1 (p/play-war-card player-1)
        new-player-2 (p/play-war-card player-2)]
    (-> game-state
        (assoc :player-1 new-player-1)
        (assoc :player-2 new-player-2))))

(defn player-1-wins [game-state]
  (let [{:keys [player-1 player-2]} game-state
        new-players (p/beats player-1 player-2)
        {:keys [new-player-1 new-player-2]} new-players]
    (-> game-state
        (assoc :player-1 new-player-1)
        (assoc :player-2 new-player-2))))

(defn player-2-wins [game-state]
  (let [{:keys [player-1 player-2]} game-state
        new-players (p/beats player-2 player-1)
        {:keys [new-player-1 new-player-2]} new-players]
    (-> game-state
        (assoc :player-1 new-player-1)
        (assoc :player-2 new-player-2))))

(defn start-war [game-state]
  (let [{:keys [player-1 player-2]} game-state
        new-player-1 (p/go-to-war player-1 3)
        new-player-2 (p/go-to-war player-2 3)]
    (-> game-state
        (assoc :player-1 new-player-1)
        (assoc :player-2 new-player-2))))

(defn resolve-war-cards [game-state]
  (let [{:keys [player-1 player-2]} game-state
        war-card-1 (:war-card player-1)
        war-card-2 (:war-card player-2)]
    (cond
      (> (:value war-card-1) (:value war-card-2)) (player-1-wins game-state)
      (< (:value war-card-1) (:value war-card-2)) (player-2-wins game-state)
      (= (:value war-card-1) (:value war-card-2)) (start-war game-state))))

(defn tick [game-state]
  (-> game-state
      (play-war-cards)
      (resolve-war-cards)))

;</excerpt>
I feel like I'm doing something horribly wrong here. There's lets everywhere, a whole bunch of code repetition, and overall the whole thing feels way too verbose and weird. I tried to take a top-down approach with programming the game, but I feel like I totally missed the mark. I'm not entirely sure if my approach of making Player and Card records was a good one, and I don't feel like I'm really taking advantage of Clojure's strengths here.

How can I improve this code, and the project in general? How would someone who's better at programming in Lisp than I am approach a problem like this? Does what I'm doing make sense, or is it in need of serious refactoring?

Pollyanna
Mar 5, 2005

Milk's on them.


I think part of the problem is that I'm constantly associng things into game-state as the return value for every function. I feel like if I have to do that so often, I might be doing it wrong. Functional data structures are kind of a pain to work with in my experience since you constantly have to haul around a reference to the top-level data structure and assoc new attributes into it, which can reach several layers deep. But maybe that's just because of how I approached the problem :(

Pollyanna
Mar 5, 2005

Milk's on them.


Okay, I'm having trouble writing idiomatic Clojure code in terms of architecture and top-down vs. bottom-up programming. I'm scaling way the gently caress back and just making a plain library that plays War.

The idea is that there is one data structure: the game.

Lisp code:
(ns libwar.domain)

(def war-example
  {:p1 {:deck [{:type :red
                :value 1}
               {:type :black
                :value 2}
               {:type :red
                :value 3}]
        :cards []}
   :p2 {:deck [{:type :black
                :value 1}
               {:type :red
                :value 2}
               {:type :black
                :value 3}]
        :cards []}
   :state :peace})
A game is just a map with two players keyed by ID, and a game state (:peace, :war, :game-over). A player is a map of their deck, and their cards on the field. Their decks and field cards basically behave like stacks. That's it. That's the only data structure that exists, and the bare minimum of information I need. I have no idea if this is the best data structure to use, but gently caress it, it works.

The hard part is coming up with the functions.

I want to just do something like this to advance the game state one tick:

Lisp code:
(defn tick-game [{:keys [state] :as game}]
  (case state
    :peace (peace-transition game)
    :war (war-transition game)
    :game-over (reset-game game)))

;; e.g. (tick-game game)
That's reasonable, right? All I need to do is implement the state transitions for a given state.

Lisp code:
(defn reset-game [game]
  war-example)

(defn war-transition [{:keys [p1 p2] :as game}]
  (if (evenly-matched? p1 p2)
    (play-cards game 3)
    (assoc (resolve-match game) :state :peace)))

(defn peace-transition [{:keys [p1 p2] :as game}]
  (if (or (no-cards? p1) (no-cards? p2))
    (assoc game :state :game-over)
    (assoc (play-cards game 1) :state :war)))
This involves some querying and updates to the data structure, so we also need to implement those functions.

Lisp code:
(defn evenly-matched? [p1 p2]
  (= (top-card-value p1) (top-card-value p2)))

(defn no-cards? [p]
  (= 0 (count (:deck p))))

(defn resolve-match [{:keys [p1 p2] :as game}]
  ; TODO: make this less awful
  (let [p1-card (first (:cards p1))
        p2-card (first (:cards p2))]
    (if (> p1-card p2-card)
      (-> game
          (update :p1 #(award-winnings % (:cards p2)))
          (update :p2 #(assoc % :cards [])))
      (-> game
          (update :p2 #(award-winnings % (:cards p1)))
          (update :p1 #(assoc % :cards [])))))
  )

(defn play-n-cards [n {:keys [deck] :as p}]
  (-> p
      (update :deck #(drop n %))
      (update :cards #(concat (vec (take n deck)) %))))

(defn play-cards [game n]
  (-> game
      (update :p1 #(play-n-cards n %))
      (update :p2 #(play-n-cards n %))))
Which also require these utility functions:

Lisp code:
(defn top-card-value [p]
  (:value (first (:cards p))))

(defn award-winnings [{:keys [deck] :as p} cards]
  ;; TODO: make less awful
  (-> p
      (assoc :deck (vec (concat deck (shuffle (concat (:cards p) cards)))))
      (assoc :cards [])))
And that should work. (tick-game war-example) results in more or less what I want, barring some vec-related bullshit.

You can tell that I'm not terribly satisfied with the results. Sure, it theoretically works, but I'm not sure how to test and debug it. lein repl doesn't seem to have any step-debugging or breakpoints, so I have to rely on println, which sucks butt. And in terms of application architecture, this ended up being top-down as opposed to bottom-up, because that's just how I'm used to doing things. It's a lot more straightforward to go from a full representation of the game as opposed to individually defining cards, then decks, then players, then the game. I'm also realizing that most of these functions should be marked private, outside of tick-game.

Plus, some of them are just plain awful. award-winnings and resolve-match are particularly ugly, and I don't feel like I understand Clojure (or Lisp in general) well enough to write these functions right the first time.

What am I missing here? Did I take a sensible approach to developing this program? What should my workflow be? What do I need to learn to be good at this language, and make things that are better than just endlessly nested functions on a data structure or two? I see people putting together loving, like, graphs and nodes and channels and systems everywhere, and I have no idea how I'd do that in Ruby, let alone Clojure. :psyduck:

Pollyanna
Mar 5, 2005

Milk's on them.


Anything that isn't CLR or game development targets Linux and OSX over Windows.

Pollyanna
Mar 5, 2005

Milk's on them.


Larger and more active development is occurring on Linux platforms for tech focused on it, which means that not only is the ecosystem more mature, it's also more likely to be up-to-date and easy to use. Popular use trumps platform.

Pollyanna
Mar 5, 2005

Milk's on them.


+1 for VMs. Not having to worry about dealing with differing environments and being free to use whatever editor/IDE you want is really beneficial.

Pollyanna
Mar 5, 2005

Milk's on them.


tekz posted:

I guess I'll start with Elixir; http://elixir-lang.org/learning.html has a few options. I'm thinking of picking up the O'Reilly book, but if anyone has another recommendation please let me know.

Programming Elixir has been pretty great so far. Most Pragprog books are good, in fact.

Pollyanna
Mar 5, 2005

Milk's on them.


So I hear a lot about Lisp's extensibility and metaprogramming capabilities, and how it's good for defining a problem in a language that maps directly to said problem domain. People talk about domain-specific languages and macros and bottom-up programming and "changing the language to suit the problem" which makes it seem a lot more like dark-arts wizardry than a programming language.

I don't really understand what this all means, and how it makes Lisp so powerful/versatile. I get the concept of domain specific languages, even if I have no idea how to make one or what makes a good DSL good, and I understand that homoiconicity means that everything is just data, including code. But I can't seem to connect those concepts to understanding and writing code that maps directly from problem domain to implementation.

It feels like there's supposed to be an aha-moment with Lisp that I haven't had yet, and I don't know what I'm missing. What am I looking for, and how do Lisp's killer features matter in programming it well?

Pollyanna
Mar 5, 2005

Milk's on them.


I think the "personal project that fits" part is what trips people up. It's hard to map the usefulness of code-as-data-as-code and macros if your typical use case is setting up a web application or making an app with a GUI.

tazjin posted:

For me the lisp aha-moment happened when I started using structural editing for it (paredit in emacs), that's when I realised how the syntax and everything is just superficial and that data is code and how the universe really works.

I went to find more information and was finally enlightened by this article about the nature of lisp.

I got that first part, where everything's the same under the hood, but apart from that I'm a little lost.

I read the article, and I think I'm starting to get it. The XML and to-do list example is a good starting point, but it feels like that's just one particular application of it, and I'm not sure how to apply it to anything else. I can see flashes of brilliance there when you realize that the entire point is about transforming data in a functional manner, but that kind of loses its luster when you work in FP-ready languages already.

The one problem I have with macros vs. functions is that everything you can do with macros, you can do with functions. At least, as far as I can tell. As someone who mostly uses FP, I reach for a data-transforming function over a macro since that's what I'm used to. I don't feel the need to use macros, almost ever, so I don't really have a reason to understand them.

What could help is to have some sort of exercise where you give people some plain s-expressions meant to represent some kind of data structure, and have people write something that makes that chunk of data into a program. I think that's the crux of the matter - the fact that programs are data in Lisp confuses people, because everyone considers programs and data to be wholly separate things in their minds. Programs do things, whereas data are things. One becoming the other never happens, perceptually, and maybe that's the problem. "Code is data" is readily understood, but "data is code" might just be the missing piece.

Here, this is something awful:

Lisp code:
(forum "Something Awful"
  (board "Games"
    (thread "DO4M"
      (post (user "Pollyanna") "here a post about a doam")))
  (board "Serious Hardware/Software Crap"
    (board "The Cavern of Cobol"
      (thread "Functional Programming: This thread is like a burrito"
        (post (user "Pollyanna") "here a post about a lisp")))))
This whole thing is a chunk of data representing (part of) the forums, and this is all the input you get. Your job, as an evaluator of Lisp's potential, is to pretty-print this in two different ways:

1. Using only functions, and
2. Using only macros.

Forums, boards, threads, posts, and users all have different pretty-print formats. For example, I want all forums to be displayed in UPPERCASE LETTERS, all users to be printed like Pollyanna says:\n, all posts to be printed like this:

code:
-----------------
here a post about a lisp
-----------------
and so on. I gotta run, but I feel like implementing this functionality in the two different ways (and maybe even a third way, a combination of both) will help illuminate Lisp's strengths.

Pollyanna fucked around with this message at 16:24 on May 19, 2016

Pollyanna
Mar 5, 2005

Milk's on them.


:shrug: I dunno, then. This is just kind of my impression of what that article was trying to teach me, and what the whole magic/zen enlightenment of Lisp is. I just wanna know how to make things really bullshit easy and dead simple, and macros seem like a good way to do that. How, I don't know, but it's what people seem to be saying about them.

Siguy posted:

I was talking a little out of my rear end since I didn't put much effort into learning Common Lisp, but all the built-in functions felt very old-school and weird to me, with lots of abbreviations and terminology I wasn't familiar with. That doesn't mean they're bad necessarily and I probably overstated saying they have no logic, but as someone new to the language I didn't understand why sometimes a common function would be a clear written out word like "concatenate" and other times a function would just be "elt" or "rplaca".

This is basically not a thing in Clojure, which is the newest dialect running on the JVM. Check it out: https://www.conj.io/

Pollyanna
Mar 5, 2005

Milk's on them.


I'm doing some Elixir practice projects and the concept of OTP and functions as processes is definitely interesting, but I'm having a hard time understanding the real world application of it and why you would want to structure a system that way, and what kind of common problems/notable systems and products call for OTP. What is it usually used for? Phoenix always talks about chat apps 'n stuff, but that seems like a different thing to me.

Pollyanna
Mar 5, 2005

Milk's on them.


MononcQc posted:

Rather than making a very long post about this from scratch, I'll source a transcript of a talk I gave on 'The Zen of Erlang' that mentions what OTP can bring to system structure: http://ferd.ca/the-zen-of-erlang.html



With supervisors (rounded squares), we can start creating deep hierarchies of processes. Here we have a system for elections, with two trees: a tally tree and a live reports tree. The tally tree takes care of counting and storing results, and the live reports tree is about letting people connect to it to see the results.

By the order the children are defined, the live reports will not run until the tally tree is booted and functional. The district subtree (about counting results per district) won't run unless the storage layer is available. The storage's cache is only booted if the storage worker pool (which would connect to a database) is operational.

The supervision strategies (one-for-one, one-for-all, rest-for-one) let us encode these requirements in the program structure, and they are still respected at run time, not just at boot time. For example, the tally supervisor may be using a one for one strategy, meaning that districts can individually fail without effecting each other's counts. By contrast, each district (Quebec and Ontario's supervisors) could be employing a rest for one strategy. This strategy could therefore ensure that the OCR process can always send its detected vote to the 'count' worker, and it can crash often without impacting it. On the other hand, if the count worker is unable to keep and store state, its demise interrupts the OCR procedure, ensuring nothing breaks.

The OCR process itself here could be just monitoring code written in C, as a standalone agent, and be linked to it. This would further isolate the faults of that C code from the VM, for better isolation or parallelisation.

Another thing I should point out is that each supervisor has a configurable tolerance to failure; the district supervisor might be very tolerant and deal with 10 failures a minute, whereas the storage layer could be fairly intolerant to failure if expected to be correct, and shut down permanently after 3 crashes an hour if we wanted it to.

In this program, critical features are closer to the root of the tree, unmoving and solid. They are unimpacted by their siblings' demise, but their own failure impacts everyone else. The leaves do all the work and can be lost fairly well — once they have absorbed the data and operated their photosynthesis on it, it is allowed to go towards the core.

So by defining all of that, we can isolate risky code in a worker with a high tolerance or a process that is being monitored, and move data to stabler process as information matures into the system. If the OCR code in C is dangerous, it can fail and safely be restarted. When it works, it transmits its information to the Erlang OCR process. That process can do validation, maybe crash on its own, maybe not. If the information is solid, it moves it to the Count process, whose job is to maintain very simple state, and eventually flush that state to the database via the storage subtree, safely independent.

If the OCR process dies, it gets restarted. If it dies too often, it takes its own supervisor down, and that bit of the subtree is restarted too — without affecting the rest of the system. If that fixes things, great. If not, the process is repeated upwards until it works, or until the whole system is taken down as something is clearly wrong and we can't cope with it through restarts.

There's enormous value in structuring the system this way because error handling is baked into its structure. This means I can stop writing outrageously defensive code in the edge nodes — if something goes wrong, let someone else (or the program's structure) dictate how to react. If I know how to handle an error, fine, I can do that for that specific error. Otherwise, just let it crash!

This tends to transform your code. Slowly you notice that it no longer contains these tons of if/else or switches or try/catch expressions. Instead, it contains legible code explaining what the code should do when everything goes right. It stops containing many forms of second guessing, and your software becomes much more readable.



When taking a step back and looking at our program structure, we may in fact find that each of the subtrees encircled in yellow seem to be mostly independent from each other in terms of what they do; their dependency is mostly logical: the reporting system needs a storage layer to query, for example.

It would also be great if I could, for example, swap my storage implementation or use it independently in other systems. It could be neat, too, to isolate the live reports system into a different node or to start providing alternative means (such as SMS for example).

What we now need is to find a way to break up these subtrees and turn them into logical units that we can compose, reuse together, and that we can otherwise configure, restart, or develop independently.



OTP applications are what Erlang uses as a solution here. OTP applications are pretty much the code to construct such a subtree, along with some metadata. This metadata contains basic stuff like version numbers and descriptions of what the app does, but also ways to specify dependencies between applications. This is useful because it lets me keep my storage app independent from the rest of the system, but still encode the tally app's need for it to be there when it runs. I can keep all the information I had encoded in my system, but now it is built out of independent blocks that are easier to reason about.

In fact, OTP applications are what people consider to be libraries in Erlang. If your code base isn't an OTP application, it isn't reusable in other systems. [Sidenote: there are ways to specify OTP libraries that do not actually contain subtrees, just modules to be reused by other libraries]

With all of this done, our Erlang system now has all of the following properties defined:
  • what is critical or not to the survival of the system
  • what is allowed to fail or not, and at which frequency it can do so before it is no longer sustainable
  • how software should boot according to which guarantees, and in what order
  • how software should fail, meaning it defines the legal states of partial failures you find yourself in, and how to roll back to a known stable state when this happens
  • how software is upgraded (because it can be upgraded live, based on the supervision structure)
  • how components interdepend on each other
This is all extremely valuable. What's more valuable is forcing every developer to think in such terms from early on. You have less defensive code, and when bad things happen, the system keeps running. All you have to do is go look at the logs or introspect the live system state and take your time to fix things, if you feel it's worth the time.

In a nutshell, the Zen of Erlang and 'let it crash' is really all about figuring out how components interact with each other, figuring out what is critical and what is not, what state can be saved, kept, recomputed, or lost. In all cases, you have to come up with a worst-case scenario and how to survive it. By using fail-fast mechanisms with isolation, links & monitors, and supervisors to give boundaries to all of these worst-case scenarios' scale and propagation, you make it a really well-understood regular failure case.

That sounds simple, but it's surprisingly good; if you feel that your well-understood regular failure case is viable, then all your error handling can fall-through to that case. You no longer need to worry or write defensive code. You write what the code should do and let the program's structure dictate the rest. Let it crash.

OTP is pretty much instrumental to that.

...Huh. That's actually pretty loving cool. I've never really thought of programming that way. Web apps, where I've spent 99% of my dev time, don't usually match up to this kind of approach - at least, not the ones I've ever worked on. I feel kinda left out now.

It seems like all the interesting software stuff is kept away in places where Rails monkeys like me don't really get the chance to work on them and learn from it. I want to get more involved in cool stuff like this, so I've been making moves towards branching out and :yotj:ing my way into one of these kinds of projects. Teams are moving towards more functional and alternative coding styles now, so I hope my skills end up in demand.

Pollyanna
Mar 5, 2005

Milk's on them.


So the common idea of "HTTP is stateless and you must assume the connection and requests are too" still holds, but individual connections are considered their own processes? What advantage does this have over the previous one-process model? I can see the obvious picks of parallelization, but there's also concerns like database access that could limit the benefits from that...I'm not a web dev genius, just a monkey, so maybe I'm missing something. As a general system architecture, too, I can see how it'd be useful, but I still need some practice and experience to really grok it.

Pollyanna
Mar 5, 2005

Milk's on them.


I can understand why you'd want to write your systems and applications as a process tree, but I haven't yet developed a sense of what use cases those would be. I guess I haven't had a need to work on uptime critical systems before, but it sure sounds interesting. I got pretty far into LYAH, so maybe LYSE is up next.

Edit: I have to say, Elixir/Erlang, OTP and the BEAM are how I expected computer programs to work in the first place, so it's nice to see that I wasn't totally off.

xtal posted:

Self-quoting for context. How much of a coding horror would it be for me to make a Lisp syntax for Rust using Haskell and parsec?

As long as it isn't a security critical thing, :getin:

Pollyanna fucked around with this message at 15:46 on Jan 18, 2017

Pollyanna
Mar 5, 2005

Milk's on them.


xtal posted:

What implications would that have for security? Do you mean it's just silly to make our own language?

I mean that anything goes really. Ignore what I said about security, I don't know anything about it.

Pollyanna
Mar 5, 2005

Milk's on them.


I don't see Elixir lasting very long without OTP also becoming a valued asset. It's a functional Ruby otherwise and it would need more of an edge than that to survive long-term.

It helps that OTP is relatively simple in concept, at least.

Pollyanna
Mar 5, 2005

Milk's on them.


I just kinda assume that any of the magical stuff that Lisp does was subsumed into modern languages long ago, so none of it is impressive or out of the ordinary for those of us using anything newer than COBOL.

Pollyanna
Mar 5, 2005

Milk's on them.


I will admit that I have trouble understanding the advantage of having direct access to the AST of a function.

Pollyanna
Mar 5, 2005

Milk's on them.


Shinku ABOOKEN posted:

can someone motivate me to learn clojure? what can it do better? a demonstration would be nice.

i keep telling myself to learn it but i cant seem to find value in it :(

The value I found in Clojure is 1. it looks cool and Lisp is cool 2. I wanted to get better at it. Other than that, you can pretty much say for 99% of everything "why would I want to do this when I can already do it with X?", so I can't really help you there cause I'm very similar to you in that regard :(

Trying to psyche myself up to try making Doom WADs in a very similar fashion, actually.

Pollyanna
Mar 5, 2005

Milk's on them.


strange posted:

I asked this in the general questions thread and was recommended to ask here:

In the year 2017, what are the compelling arguments to choose between Common Lisp, Scheme and Clojure for side project webdev?

Context:
I get the impression that CL spec is a touch outdated.

I like functional programming, pattern matching and algebraic data types are fantastic.

I write c#/js by day.

Oh great Lisp greybeards, lend me your knowledge!

Side project web dev, specifically? Clojure by far. Clojure has a pretty robust selection of web development libraries ranging from basic HTTP handling, to SQL abstraction, to API management, and there's at least one legit web dev book to learn from.

Disclaimer: I am basically the opposite of a greybeard, so take my advice with a grain of salt. But, I have had good experiences doing web dev in Clojure!

Pollyanna
Mar 5, 2005

Milk's on them.


My one gripe with Clojure is the lack of an ORM, which is entirely because my SQL skills are relatively lacking. :negative:

Pollyanna
Mar 5, 2005

Milk's on them.


True. Datomic is a better fit overall for Clojure, I just don't have a whole lot of familiarity with it. It makes sense to lean on it first, though.

Adbot
ADBOT LOVES YOU

Pollyanna
Mar 5, 2005

Milk's on them.


xtal posted:

The best ORM is no ORM

Fair enough, and I do remember this sort of SQL templating happening when I made stuff with Luminus.

  • Locked thread