Skip to content

Commit 26f2908

Browse files
committed
no-mistakes(review): Read naming.collections in TS, keep it in probes, add tests
1 parent b0220c3 commit 26f2908

6 files changed

Lines changed: 242 additions & 2 deletions

File tree

‎server/csharp/MetaObjects.Codegen/CodegenCli.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,7 @@ private static IEnumerable<PhysicalNaming> NamingCandidates(PhysicalNaming used)
815815
var tables = NamingStrategies.Tables;
816816
foreach (var t in tables.OrderBy(t => t == used.Tables ? 0 : 1))
817817
foreach (var c in columns.OrderBy(c => c == used.Columns ? 0 : 1))
818-
if (new PhysicalNaming(c, t) is var candidate && candidate != used)
818+
if (new PhysicalNaming(c, t) { Collections = used.Collections } is var candidate && candidate != used)
819819
yield return candidate;
820820
}
821821

‎server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
// type-checks it. It loads under the strict
44
// loader on object.entity / projection / report.
55

6+
using System.Text.Json;
67
using MetaObjects;
78
using MetaObjects.Loader;
9+
using MetaObjects.Meta;
810
using Xunit;
911

1012
namespace MetaObjects.Conformance.Tests;
@@ -46,4 +48,39 @@ public void It_is_shared_vocabulary_marked_by_implementing_port()
4648
var attr = registry.Find("object", "entity")!.Attributes.Single(a => a.Name == "collectionName");
4749
Assert.Equal(new[] { "csharp" }, attr.ImplementedIn);
4850
}
51+
[Fact]
52+
public void The_standard_pluralizer_matches_the_shared_naming_conformance_corpus()
53+
{
54+
string repoRoot = Path.GetDirectoryName(Path.GetDirectoryName(CorpusRoot.Path)!)!;
55+
using var doc = JsonDocument.Parse(File.ReadAllText(
56+
Path.Combine(repoRoot, "fixtures", "naming-conformance", "already-plural-pluralize.json")));
57+
var cases = doc.RootElement.GetProperty("cases").EnumerateArray().ToList();
58+
Assert.NotEmpty(cases);
59+
foreach (var c in cases)
60+
{
61+
string name = c.GetProperty("name").GetString()!;
62+
Assert.Equal((name, c.GetProperty("apiPlural").GetString()), (name, CollectionNames.StandardPluralize(name)));
63+
}
64+
}
65+
66+
[Theory]
67+
[InlineData(null, "CaseHistories")]
68+
[InlineData("standard", "CaseHistories")]
69+
[InlineData("none", "CaseHistory")]
70+
public void An_object_without_collectionName_takes_the_project_pluralizer(string? collections, string expected)
71+
{
72+
const string model = """
73+
{ "metadata.root": { "package": "acme", "children": [
74+
{ "object.entity": { "name": "CaseHistory", "children": [ { "field.long": { "name": "id" } } ] } },
75+
{ "object.entity": { "name": "Diagnosis", "@collectionName": "DiagnosisRows", "children": [ { "field.long": { "name": "id" } } ] } }
76+
] } }
77+
""";
78+
var result = new MetaDataLoader(Provider.ComposeRegistry(CoreTypes.LibraryProviders), strict: true)
79+
.Load([new InMemoryStringSource(model, id: "meta.json")]);
80+
Assert.Empty(result.Errors.Select(e => e.Code + ": " + e.Message));
81+
result.Root.PhysicalNaming = PhysicalNaming.Default with { Collections = collections };
82+
var objects = result.Root.Objects().ToDictionary(o => o.Name);
83+
Assert.Equal(expected, CollectionNames.Of(objects["CaseHistory"]));
84+
Assert.Equal("DiagnosisRows", CollectionNames.Of(objects["Diagnosis"]));
85+
}
4986
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.metaobjects.database;
2+
3+
import com.google.gson.JsonArray;
4+
import com.google.gson.JsonObject;
5+
import com.google.gson.JsonParser;
6+
import com.metaobjects.MetaDataException;
7+
import com.metaobjects.config.NeutralConfig;
8+
import org.junit.After;
9+
import org.junit.Test;
10+
11+
import java.io.IOException;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.util.Comparator;
15+
import java.util.List;
16+
import java.util.stream.Stream;
17+
18+
import static org.junit.Assert.assertEquals;
19+
import static org.junit.Assert.assertFalse;
20+
import static org.junit.Assert.assertNull;
21+
import static org.junit.Assert.assertThrows;
22+
import static org.junit.Assert.assertTrue;
23+
24+
/**
25+
* {@code naming.collections} names the pluralizer an object without {@code @collectionName}
26+
* takes its collection name from. Java generates nothing from it yet, but a project shared with
27+
* a port that does carries the key, so Java reads it, refuses an unknown name like
28+
* {@code columns} and {@code tables}, and keeps the selected name on the loaded naming.
29+
*/
30+
public class CollectionNamingTest {
31+
32+
@After
33+
public void reset() {
34+
NamingStrategies.resetForTesting();
35+
}
36+
37+
private static PhysicalNaming readNaming(String naming) throws IOException {
38+
Path dir = Files.createTempDirectory("mo-collection-naming-");
39+
try {
40+
Files.createDirectory(dir.resolve(".metaobjects"));
41+
Files.writeString(dir.resolve(".metaobjects").resolve("config.json"),
42+
"{ \"schema_version\": 1, \"naming\": " + naming + " }");
43+
return NeutralConfig.read(dir).orElseThrow().getPhysicalNaming();
44+
} finally {
45+
try (Stream<Path> paths = Files.walk(dir)) {
46+
paths.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
47+
}
48+
}
49+
}
50+
51+
@Test
52+
public void theBuiltInsPluralizeOrKeepTheName() {
53+
assertEquals(List.of("standard", "none"), NamingStrategies.BUILT_IN_PLURALIZERS);
54+
assertEquals("standard", NamingStrategies.DEFAULT_PLURALIZER);
55+
assertEquals("CaseHistories", NamingStrategies.pluralizer("standard").apply("CaseHistory"));
56+
assertEquals("CaseHistory", NamingStrategies.pluralizer("none").apply("CaseHistory"));
57+
}
58+
59+
@Test
60+
public void standardMatchesTheSharedNamingConformanceCorpus() throws IOException {
61+
Path repoRoot = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize();
62+
Path fixture = repoRoot.resolve("fixtures/naming-conformance/already-plural-pluralize.json");
63+
JsonArray cases = JsonParser.parseString(Files.readString(fixture)).getAsJsonObject().getAsJsonArray("cases");
64+
assertFalse(cases.isEmpty());
65+
for (int i = 0; i < cases.size(); i++) {
66+
JsonObject c = cases.get(i).getAsJsonObject();
67+
String name = c.get("name").getAsString();
68+
assertEquals(name, c.get("apiPlural").getAsString(), NamingStrategies.standardPluralize(name));
69+
}
70+
}
71+
72+
@Test
73+
public void theConfigReadsABuiltIn() throws IOException {
74+
assertEquals("none", readNaming("{ \"collections\": \"none\" }").collections());
75+
}
76+
77+
@Test
78+
public void absentNamesNoPluralizer() throws IOException {
79+
assertNull(readNaming("{ \"columns\": \"snake_case\" }").collections());
80+
}
81+
82+
@Test
83+
public void anUnknownPluralizerIsRefusedNamingTheKind() {
84+
MetaDataException ex = assertThrows(MetaDataException.class,
85+
() -> readNaming("{ \"collections\": \"latin\" }"));
86+
assertTrue(ex.getMessage(), ex.getMessage().contains("\"naming.collections\" names \"latin\""));
87+
assertTrue(ex.getMessage(), ex.getMessage().contains("Java port"));
88+
}
89+
90+
@Test
91+
public void anApplicationPluralizerIsSelectable() throws IOException {
92+
NamingStrategies.registerPluralizer("java-test-shout", n -> n.toUpperCase() + "S");
93+
assertTrue(NamingStrategies.registeredPluralizers().contains("java-test-shout"));
94+
assertEquals("java-test-shout", readNaming("{ \"collections\": \"java-test-shout\" }").collections());
95+
NamingStrategies.resetForTesting();
96+
assertFalse(NamingStrategies.isPluralizer("java-test-shout"));
97+
}
98+
99+
@Test
100+
public void aBuiltInPluralizerCannotBeReplaced() {
101+
assertThrows(IllegalArgumentException.class, () -> NamingStrategies.registerPluralizer("none", n -> n));
102+
assertThrows(IllegalArgumentException.class, () -> NamingStrategies.registerPluralizer("standard", n -> n));
103+
}
104+
}

‎server/python/src/metaobjects/cli.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2320,7 +2320,7 @@ def naming_candidates(used: NamingRules) -> list[NamingRules]:
23202320
candidate
23212321
for t in tables
23222322
for c in columns
2323-
if (candidate := NamingRules(columns=c, tables=t)) != used
2323+
if (candidate := NamingRules(columns=c, tables=t, collections=used.collections)) != used
23242324
]
23252325

23262326

‎server/typescript/packages/sdk/src/config.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DEFAULT_PHYSICAL_NAMING,
88
isRegisteredColumnNamingStrategy,
99
isRegisteredTableNamingRule,
10+
isRegisteredPluralizer,
1011
unknownNamingStrategyMessage,
1112
DEFAULT_WIRE_FORMAT,
1213
DEFAULT_ENUM_DEFAULTS,
@@ -227,6 +228,8 @@ export const ConfigSchema = z.object({
227228
* the PROJECT, read here by every port, because every port reads and writes the one database
228229
* `meta migrate` created: codegen, migrate, the runtime and verify all resolve through it.
229230
* Absent means `snake_case` for both, which is what `meta migrate` has always created.
231+
* `collections` names the pluralizer (`standard`, `none` or one registered with
232+
* `registerPluralizer`) an object without `@collectionName` takes its collection name from.
230233
*
231234
* Each value is the NAME of a registered strategy (ADR-0058 Amendment 1): a built-in
232235
* (`columns`: `snake_case`, `literal`, `kebab-case`, `pascal`; `tables`: `snake_case`,
@@ -247,6 +250,11 @@ export const ConfigSchema = z.object({
247250
if (!isRegisteredTableNamingRule(n)) ctx.addIssue({ code: "custom", message: unknownNamingStrategyMessage("tables", n) });
248251
})
249252
.optional(),
253+
collections: z.string()
254+
.superRefine((n, ctx) => {
255+
if (!isRegisteredPluralizer(n)) ctx.addIssue({ code: "custom", message: unknownNamingStrategyMessage("collections", n) });
256+
})
257+
.optional(),
250258
})
251259
.strict()
252260
.optional(),
@@ -316,6 +324,7 @@ export function physicalNamingFromConfig(cfg: Pick<Config, "naming">): PhysicalN
316324
return {
317325
columns: cfg.naming?.columns ?? DEFAULT_PHYSICAL_NAMING.columns,
318326
tables: cfg.naming?.tables ?? DEFAULT_PHYSICAL_NAMING.tables,
327+
...(cfg.naming?.collections !== undefined ? { collections: cfg.naming.collections } : {}),
319328
};
320329
}
321330

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// `naming.collections` names the pluralizer an object without `@collectionName` takes its
2+
// collection name from. The config reader accepts a registered pluralizer, refuses an unknown
3+
// one when the file is read, and the loaded root carries the selection to `collectionNameOf`.
4+
5+
import { afterEach, describe, expect, test } from "bun:test";
6+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import { join, resolve } from "node:path";
9+
import {
10+
InMemoryStringSource,
11+
MetaDataLoader,
12+
collectionNameOf,
13+
isMetaRoot,
14+
pluralizer,
15+
registerPluralizer,
16+
resetNamingStrategiesForTesting,
17+
type MetaRoot,
18+
} from "@metaobjectsdev/metadata";
19+
import { loadConfig, physicalNamingFromConfig } from "../src/config.js";
20+
21+
const PLURALS = resolve(import.meta.dir, "../../../../../fixtures/naming-conformance/already-plural-pluralize.json");
22+
23+
afterEach(resetNamingStrategiesForTesting);
24+
25+
async function readConfigWith(naming: unknown) {
26+
const dir = mkdtempSync(join(tmpdir(), "mo-collection-naming-"));
27+
try {
28+
writeFileSync(join(dir, "config.json"), JSON.stringify({ schema_version: 1, naming }));
29+
return await loadConfig(dir);
30+
} finally {
31+
rmSync(dir, { recursive: true, force: true });
32+
}
33+
}
34+
35+
async function loadModel(): Promise<MetaRoot> {
36+
const json = JSON.stringify({
37+
"metadata.root": {
38+
package: "acme",
39+
children: [
40+
{ "object.entity": { name: "CaseHistory", children: [{ "field.long": { name: "id" } }] } },
41+
{ "object.entity": { name: "Diagnosis", "@collectionName": "DiagnosisRows", children: [{ "field.long": { name: "id" } }] } },
42+
],
43+
},
44+
});
45+
const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]);
46+
expect(result.errors).toEqual([]);
47+
if (!isMetaRoot(result.root)) throw new Error("not a root");
48+
return result.root;
49+
}
50+
51+
describe("naming.collections", () => {
52+
test("the standard pluralizer matches the shared naming-conformance corpus", () => {
53+
const corpus = JSON.parse(readFileSync(PLURALS, "utf8")) as { cases: { name: string; apiPlural: string }[] };
54+
expect(corpus.cases.length).toBeGreaterThan(0);
55+
for (const c of corpus.cases) expect([c.name, pluralizer("standard")(c.name)]).toEqual([c.name, c.apiPlural]);
56+
});
57+
58+
test("the config reads a built-in and the root carries it to collectionNameOf", async () => {
59+
const config = await readConfigWith({ collections: "none" });
60+
expect(config.naming?.collections).toBe("none");
61+
const root = await loadModel();
62+
root.physicalNaming = physicalNamingFromConfig(config);
63+
expect(collectionNameOf(root.findObject("CaseHistory")!)).toBe("CaseHistory");
64+
expect(collectionNameOf(root.findObject("Diagnosis")!)).toBe("DiagnosisRows");
65+
});
66+
67+
test("absent, an object takes the standard pluralizer", async () => {
68+
const config = await readConfigWith({ columns: "snake_case" });
69+
expect(physicalNamingFromConfig(config).collections).toBeUndefined();
70+
const root = await loadModel();
71+
root.physicalNaming = physicalNamingFromConfig(config);
72+
expect(collectionNameOf(root.findObject("CaseHistory")!)).toBe("CaseHistories");
73+
});
74+
75+
test("an unknown pluralizer is refused when the config is read", async () => {
76+
await expect(readConfigWith({ collections: "latin" })).rejects.toThrow(/"naming\.collections" names "latin"/);
77+
});
78+
79+
test("an application pluralizer is selectable by name", async () => {
80+
registerPluralizer("ts-test-shout", (n) => n.toUpperCase() + "S");
81+
const config = await readConfigWith({ collections: "ts-test-shout" });
82+
const root = await loadModel();
83+
root.physicalNaming = physicalNamingFromConfig(config);
84+
expect(collectionNameOf(root.findObject("CaseHistory")!)).toBe("CASEHISTORYS");
85+
});
86+
87+
test("a built-in pluralizer cannot be replaced", () => {
88+
expect(() => registerPluralizer("none", (n) => n)).toThrow();
89+
});
90+
});

0 commit comments

Comments
 (0)