Repository: sqlfluff/sqlfluff

Issue:
Public API to build nested configs
### Search before asking

- [X] I searched the [issues](https://github.com/sqlfluff/sqlfluff/issues) and found no similar issues.


### Description

I want to have a public API to be able to build `FluffConfig` instance from several config files which override one another, replicating the behaviour with nested `.sqlfluff` configs in the filesystem.

### Use case

I'm building a lint functionality which uses SQLFluff. I want the user to be able to provide nested `.sqlfluff` config files in the service's virtual filesystem, so I need to build the config in my own code.

I have tried using `nested_combine` like this:

```Python
class LintConfigFile:
    content: str

def build_config(config_files: Optional[List[LintConfigFile]]) -> FluffConfig:
    if not config_files:
        return None

    loader = ConfigLoader.get_global()
    config_dicts = [
        loader.load_config_string(config_file.content)
        for config_file in config_files
    ]
    config = nested_combine(*config_dicts)
    return FluffConfig.from_kwargs(config=config)
```

However, this fails to combine contents of two different config files with this simple unit test:

```
        def test_build_config_multiple(self):
            config = build_config(
                [
                    LintConfigFile(
                        content="""
    [sqlfluff]
    dialect = duckdb
    """
                    ),
                    LintConfigFile(
                        content="""
    [sqlfluff]
    max_line_length = 120
    """
                    ),
                ]
            )
>           assert config.get("dialect") == "duckdb"
E           AssertionError: assert None == 'duckdb'
```

So I ended up using this:

```Python
def build_config(
    config_files: Optional[List[LintConfigFile]],
) -> Optional[FluffConfig]:
    if not config_files:
        return None

    # We build a temporary directory with the config files to replicate sqlfluff
    # nesting config behavior
    with tempfile.TemporaryDirectory(dir=TMP_DIR) as temp_dir_path:
        cur_path = temp_dir_path
        for i, config_file in enumerate(config_files):
            cur_path = cur_path + f"/config-{i}"
            os.makedirs(cur_path)
            config_path = f"{cur_path}/.sqlfluff"
            with open(config_path, "w") as f:
                f.write(config_file.content)
        return FluffConfig.from_path(cur_path)
```

Which behaves exactly as I expect, but looks to be very unoptimal.

### Dialect

not related to particular dialect

### Are you willing to work on and submit a PR to address the issue?

- [X] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/sqlfluff/sqlfluff/blob/main/CODE_OF_CONDUCT.md)


Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/63517000/badge)](https://coveralls.io/builds/63517000)

coverage: 100.0%. remained the same when pulling **b56676a906ac46fe7d1b46dcef61958fd8b7af77 on ac/config** into **65328e6b701f46ebb25b758c10b7572111f130ab on main**.

github-actions[bot]: # Coverage Results ✅
```
Name    Stmts   Miss  Cover   Missing
-------------------------------------
TOTAL   16831      0   100%

219 files skipped due to complete coverage.

```


Patch:
diff --git a/examples/05_simple_api_config.py b/examples/05_simple_api_config.py
index 1f52f9e60..a8dfe885d 100644
--- a/examples/05_simple_api_config.py
+++ b/examples/05_simple_api_config.py
@@ -19,9 +19,17 @@ sqlfluff.fix("SELECT  1", config_path="test/fixtures/.sqlfluff")
 config = FluffConfig(configs={"core": {"dialect": "bigquery"}})
 # 3b. FluffConfig objects can be created from a config file in a string.
 config = FluffConfig.from_string("[sqlfluff]\ndialect=bigquery\n")
-# 3c. FluffConfig objects can be created from a path containing a config file.
+# 3c. FluffConfig objects can be created from a config file in multiple strings
+#     to simulate the effect of multiple nested config strings.
+config = FluffConfig.from_strings(
+    # NOTE: Given these two strings, the resulting dialect would be "mysql"
+    # as the later files take precedence.
+    "[sqlfluff]\ndialect=bigquery\n",
+    "[sqlfluff]\ndialect=mysql\n",
+)
+# 3d. FluffConfig objects can be created from a path containing a config file.
 config = FluffConfig.from_path("test/fixtures/")
-# 3c. FluffConfig objects can be from keyword arguments
+# 3e. FluffConfig objects can be from keyword arguments
 config = FluffConfig.from_kwargs(dialect="bigquery", rules=["LT01"])
 
 # The FluffConfig is then provided via a config argument.
diff --git a/src/sqlfluff/core/config.py b/src/sqlfluff/core/config.py
index 997d537d8..14f3825ef 100644
--- a/src/sqlfluff/core/config.py
+++ b/src/sqlfluff/core/config.py
@@ -1,5 +1,7 @@
 """Module for loading config."""
 
+from __future__ import annotations
+
 try:
     from importlib.resources import files
 except ImportError:  # pragma: no cover
@@ -359,7 +361,7 @@ class ConfigLoader:
         self._config_cache: Dict[str, Dict[str, Any]] = {}
 
     @classmethod
-    def get_global(cls) -> "ConfigLoader":
+    def get_global(cls) -> ConfigLoader:
         """Get the singleton loader."""
         global global_loader
         if not global_loader:
@@ -1022,7 +1024,7 @@ class FluffConfig:
         ignore_local_config: bool = False,
         overrides: Optional[Dict[str, Any]] = None,
         **kw: Any,
-    ) -> "FluffConfig":
+    ) -> FluffConfig:
         """Loads a config object just based on the root directory."""
         loader = ConfigLoader.get_global()
         c = loader.load_config_up_to_path(
@@ -1046,8 +1048,8 @@ class FluffConfig:
         ignore_local_config: bool = False,
         overrides: Optional[Dict[str, Any]] = None,
         plugin_manager: Optional[pluggy.PluginManager] = None,
-    ) -> "FluffConfig":
-        """Loads a config object given a particular path."""
+    ) -> FluffConfig:
+        """Loads a config object from a single config string."""
         loader = ConfigLoader.get_global()
         c = loader.load_config_string(config_string)
         return cls(
@@ -1058,6 +1060,35 @@ class FluffConfig:
             plugin_manager=plugin_manager,
         )
 
+    @classmethod
+    def from_strings(
+        cls,
+        *config_strings: str,
+        extra_config_path: Optional[str] = None,
+        ignore_local_config: bool = False,
+        overrides: Optional[Dict[str, Any]] = None,
+        plugin_manager: Optional[pluggy.PluginManager] = None,
+    ) -> FluffConfig:
+        """Loads a config object given a series of nested config strings.
+
+        Config strings are incorporated from first to last, treating the
+        first element as the "root" config, and then later config strings
+        will take precedence over any earlier values.
+        """
+        loader = ConfigLoader.get_global()
+        config_state: Dict[str, Any] = {}
+        for config_string in config_strings:
+            config_state = loader.load_config_string(
+                config_string, configs=config_state
+            )
+        return cls(
+            configs=config_state,
+            extra_config_path=extra_config_path,
+            ignore_local_config=ignore_local_config,
+            overrides=overrides,
+            plugin_manager=plugin_manager,
+        )
+
     @classmethod
     def from_path(
         cls,
@@ -1066,7 +1097,7 @@ class FluffConfig:
         ignore_local_config: bool = False,
         overrides: Optional[Dict[str, Any]] = None,
         plugin_manager: Optional[pluggy.PluginManager] = None,
-    ) -> "FluffConfig":
+    ) -> FluffConfig:
         """Loads a config object given a particular path."""
         loader = ConfigLoader.get_global()
         c = loader.load_config_up_to_path(
@@ -1085,12 +1116,12 @@ class FluffConfig:
     @classmethod
     def from_kwargs(
         cls,
-        config: Optional["FluffConfig"] = None,
+        config: Optional[FluffConfig] = None,
         dialect: Optional[str] = None,
         rules: Optional[List[str]] = None,
         exclude_rules: Optional[List[str]] = None,
         require_dialect: bool = True,
-    ) -> "FluffConfig":
+    ) -> FluffConfig:
         """Instantiate a config from either an existing config or kwargs.
 
         This is a convenience method for the ways that the public classes
@@ -1143,7 +1174,7 @@ class FluffConfig:
                 "{}".format(templater_name, ", ".join(templater_lookup.keys()))
             )
 
-    def make_child_from_path(self, path: str) -> "FluffConfig":
+    def make_child_from_path(self, path: str) -> FluffConfig:
         """Make a child config at a path but pass on overrides and extra_config_path."""
         return self.from_path(
             path,
@@ -1153,7 +1184,7 @@ class FluffConfig:
             plugin_manager=self._plugin_manager,
         )
 
-    def diff_to(self, other: "FluffConfig") -> Dict[str, Any]:
+    def diff_to(self, other: FluffConfig) -> Dict[str, Any]:
         """Compare this config to another.
 
         Args:


Test patch:
diff --git a/test/core/config_test.py b/test/core/config_test.py
index 673b80619..707f98bcb 100644
--- a/test/core/config_test.py
+++ b/test/core/config_test.py
@@ -100,6 +100,19 @@ def test__config__load_from_string():
     assert cfg == config_a
 
 
+def test__config__from_strings():
+    """Test loading config from multiple strings."""
+    strings = [
+        "[sqlfluff]\ndialect=mysql\ntesting_val=foobar",
+        "[sqlfluff]\ndialect=postgres\ntesting_val2=bar",
+        "[sqlfluff]\ndialect=mysql\ntesting_val=foo",
+    ]
+    cfg = FluffConfig.from_strings(*strings)
+    assert cfg.get("dialect") == "mysql"
+    assert cfg.get("testing_val2") == "bar"
+    assert cfg.get("testing_val") == "foo"
+
+
 def test__config__load_file_f():
     """Test loading config from a file path."""
     c = ConfigLoader()