Database-backed Gettext
Opened this issue · 14 comments
Hi!
Hope you're well.
I and my team recently thought about integrating gettext with the database as a persistence point; unfortunately, we've stumbled upon this topic on the elixir forum: https://elixirforum.com/t/gettext-backed-by-db-ecto/19099 that suggested alternate solutions, with concerns that it might not be achievable.
However, we believe this is the best place to ask about such an issue; if possible, I would be more than happy to help make it happen.
One of the solutions we imagined might help could be:
- Allow configuring gettext to delegate function calls to a specified custom module, and - based on returned value - either return it further or fall back to calling original Gettext module functions.
I'm open to discussion.
👋
👋
Please go ahead and explore this discussion. I believe all of the lookups happen through lgettext and lngettext functions, so you should be able to replace them in a custom backend to at least validate the approach:
defoverridable lgettext: ..., lngettext: ...
defp lgettext(...) do # now lookup in the database
Alternatively you can mix the projects and use handle_missing_translation to only go to the database when you don't have an alternative.
So there should be no roadblocks for exploration and we can circle back once there is something you are pleased with. :)
Hi @czwartyeon, do you have any updates here? :)
I've been thinking about this problem because we've been looking into an option to automatically update translations without having to rebuild our applications and redeploy everything and I think I have an idea that could be generic enough.
I particularly don't think it would be wise to hit the database for every translation request, so my idea would be to have, apart from the compile-time lgettext
/lngettext
, a backend that reads that from a given ETS table.
If we standardize this ETS table, we could then have different "syncers" that would fetch the translations, parse, and insert into the table and those would live on different libraries.
An example library, would get all pot
files and then query a KV (redis or etcd) for po
files, then it would parse these files and insert all strings in the ETS table.
There could be a lot of different approaches that could get binary po
files from a database or even the actual strings directly from the database.
That being said, I think having a standard ETS format would be beneficial for these "runtime" loading and maybe could ship with a loader that just reads the .po
files from a path in the system, which would at least allow to change the po
files without recompiling elixir (this might be useful for cases where the app is delivered compiled but we still want to provide OTA localization updates, like in a Nerves device, for example).
If you think this approach would make sense, I can start working on a library to implement (my understanding is that I can quite easily do that without having to change Gettext, but I'll probably need to "re-implement" the use Gettext
macro.
Exploring this approach is certainly very welcome!
@josevalim today I managed to give this a go and I think I made it work, but still a proof of concept.
I created this repository: https://github.com/bamorim/runtime_gettext
It contains three projects:
rg_demo
: A demo phoenix app just to show how something would look like in real liferuntime_gettext
: The core that I'll talk more about nextruntime_gettext_po
: A utility to load translations from po files and save them on theRuntimeGettext.ETSRepo
RuntimeGettext
The RuntimeGettext library is composed by:
RuntimeGettext
Implements a macro that does the defoverridable
and implements lgettext
and lngettext
to load strings in runtime from a RuntimeGettext.Repo
, which by default is RuntimeGettext.ETSRepo
. If the repo returns that it doesn't have the translation, this calls super
to use the compile-time functions.
One the few problems I faced here are:
- I had to re-implement the call to the
Gettext.Plural
to get the plural form. Maybe if we had an extension point with the plural form "built in" that would be useful (maybe that is also useful to make thehandle_missing_translation
easier as well). - I had to re-implement the call to the Interpolation module, again, maybe an extension point that is just about "get me the translation string and I'll do the rest" could be useful. Of course, in this case I had to call
runtime_interpolate
instead of the compile time it is being called on the default gettext implementation. - The configs to make it "the same" as gettext, I had to reimplement the call. Maybe I should have used
__gettext__
and assumed it was a public interface? Regardless, the current__gettext__
implementation doesn't include the:plural_forms
option, so maybe we should include that so in this code I just call__gettext__(:plural_forms)
and__gettext__(:interpolation)
RuntimeGettext.Repo
This is just a behaviour to get the translation strings in runtime. It is basically:
@callback get_translation(locale(), domain(), msgctxt(), msgid()) ::
{:ok, msgstr()} | {:error, :translation_not_found}
@callback get_plural_translation(locale(), domain(), msgctxt(), msgid(), plural_form()) ::
{:ok, msgstr()} | {:error, :translation_not_found}
RuntimeGettext.ETSRepo
This saves msgstrs in an ETS table and implements the RuntimeGettext.Repo
behaviour to return the saved strings.
It also exposes an add_translation
and add_plural_translation
to be used by the "fetchers". For now it doesn't have any functions to remove existing translations nor to list all translations, but these should be easy to implement.
Right now, I'm also not checking anything (like if the values are the correct type), but this should probably be considered.
RuntimeGettextPO
This is just a proof of concept that exposes the function: RuntimeGettextPO.load_po_files(path)
so if one wants to use the exact same structure as gettext has by default, lets say in priv/gettext
but load the actual values on runtime (so they can be, for example, just mounted in a docker image instead of requiring re-compilation of the whole project), one could just
RuntimeGettextPO.load_po_files(Application.app_dir(:my_otp_app, "priv/gettext"))
For example, in their OTP application (similar to how one would setup telemetry, for example).
Of course, more sophisticated approaches could come from that, like listening to filesystem events or polling from there, which maybe, in turn, could be tied together with, for example, an s3 bucket and a s3 fuse adapter to sync the files.
My goal here was to just give a kickstart to this, but now it is already late, but I can try to organize this better later.
Next Steps
I'm not sure how we could proceed here. If we want to go the route of first having this as a separate library, I can go ahead and split the repository into multiple repos and also publish them to Hex.pm. I also don't know if you want to have that on the elixir-gettext
organization, it is up to you.
That's all for now. I'm really tired hahaha.
this looks great! What we probably want to do is to allow multiple repos to be given. Then we go through each repo in order and if they don’t find something they return :none
. This way we can also keep the call to super.
If we do find something then we call the Interpolator. Otherwise we say translation not found.
Would you like to submit a request to this repository exploring how it would look like? Thank you!
Hey, thanks for the response. I like the idea of multiple repos, I can definitely give it a try. However, at the same time, it might be putting a lot of complexity unnecessarily as one could easily create a repo that does exactly that: look into multiple sources.
But I can explore this idea, for sure.
One thing I didn't got is "Otherwise we say translation not found". This means we call super?
Also, when you say "submit a request to this repo" are you referring to bamorim/runtime_gettext or elixir-gettext/gettext?
The idea of multiple repos is to solve the problem with super. This way it is your choice to keep the default behavior as a fallback or not.
If none of the repositories have a translation, then we return the error translation not found tuple.
And I meant a pull request to this repo, yeah :)
So one of the repos would be the one "calling super"? I don't see how that is possible since only the function can call super. Maybe if the repo just returns :super instead.
Unless we change the compiler to create the default compile-time behavior as a repo (either in a nested module or in that same module) and the default repos being [__MODULE__]
or [__MODULE__.Repo]
I see what you mean. So I agree with you, there is no need for a list, we just call a repository module if any was configured.
Okay, I'll open a PR with a proposal on how that would look like "natively" here.
Closing in favor of the PR. 💯