Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
/CHANGELOG.md -export-ignore
/composer.json -export-ignore
/LICENSE -export-ignore
/map.json -export-ignore
/README.md -export-ignore
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For this reason, we have written this plugin, which — in addition to addressin
- [memory limit](#memory-limit)
- [metadata limit](#metadata)
- [multiple options](#multiple-options)
- [Namespace mapping](#namespace-mapping)
- [Generated code guide](#generated-code-guide)
- [numbers](#numbers)
- [repeated](#repeated)
Expand Down Expand Up @@ -242,6 +243,56 @@ protoc \
--php-plugin_out=genproto
```

### Namespace mapping

Thesis is an alternative runtime, therefore its generated code must be able to live in the same project as the code
generated by the standard `google/protobuf` runtime. That is impossible as long as both generate, say, `Google\Protobuf\Any`:
the class names collide, which is why `thesis/protobuf-known-types` used to declare `conflict: google/protobuf` — and that
conflict, in turn, made it impossible to use packages tied to `google/protobuf` (`open-telemetry/*`, for instance).

To solve this, the plugin relocates well-known packages into namespaces of its own. The relocation table is the
[`map.json`](map.json) file shipped with the plugin (it is bundled into the phar and into the docker image, so it applies no
matter how the plugin is installed):

```json
{
"rules": [
{"from": "google.protobuf.*", "to": "thesis.google.protobuf.*"},
{"from": "google.rpc.Status", "to": "thesis.google.rpc.Status"}
]
}
```

A rule is written in terms of protobuf names, not PHP ones, the PHP namespace is derived from the rewritten name exactly
the same way it is derived from a package:

| `from` | `to` | effect |
|---------------------|----------------------------|---------------------------------------------------------------------------------------------------------------|
| `google.protobuf.*` | `thesis.google.protobuf.*` | pattern: the package itself and everything nested in it, `Google\Protobuf\Any` → `Thesis\Google\Protobuf\Any` |
| `google.rpc.Status` | `thesis.google.rpc.Status` | exact: one type only, `Google\Rpc\Status` → `Thesis\Google\Rpc\Status` |

* both sides of a rule must be of the same kind: either both end with `.*`, or neither does;
* an exact rule wins over a pattern, and a longer pattern wins over a shorter one;
* names are rewritten in one pass — the result of a rule is never fed into another rule;
* a rule may only move a type, not rename it: the last segment of `from` and `to` must be equal;
* a rule wins over the `php_namespace` option, both the one from the schema and the one passed on the command line —
a type is relocated precisely because its default namespace is already taken.

The rewriting never leaves PHP. Protobuf names themselves stay exactly as the schema declares them, so
`DescriptorRegistry` keeps registering `google.protobuf.Timestamp`:

```php
$pool->add(Registry\Descriptor::base64(self::TIMESTAMP_DESCRIPTOR_BUFFER), new File(
name: 'google/protobuf/timestamp.proto',
messages: [
new File\MessageDescriptor('google.protobuf.Timestamp', \Thesis\Google\Protobuf\Timestamp::class),
],
));
```

That is what keeps `Any` type urls and server reflection interoperable: other languages know nothing about
`thesis.google.protobuf.Timestamp`.

### Generated code guide

As mentioned above, the plugin generates simple DTOs without setters, getters, or inheritance. All metadata for protobuf serialization is stored in attribute `Thesis\Protobuf\Reflection\*`, and the generated DTOs only have a constructor with promoted properties.
Expand Down
2 changes: 1 addition & 1 deletion bin/compiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
$decoder = Decoder\Builder::buildDefault();

$entrypoint = new Protoc\Entrypoint(
new Plugin\Compiler($encoder),
new Plugin\Compiler($encoder, __DIR__ . '/../map.json'),
$encoder,
$decoder,
);
Expand Down
3 changes: 3 additions & 0 deletions box.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"src",
"vendor"
],
"files": [
"map.json"
],
"main": "bin/compiler.php",
"output": "protoc-gen-php",
"shebang": "#!/usr/bin/env php"
Expand Down
11 changes: 11 additions & 0 deletions map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"rules": [
{"from": "google.protobuf.*", "to": "thesis.google.protobuf.*"},
{"from": "google.rpc.*", "to": "thesis.google.rpc.*"},
{"from": "google.api.*", "to": "thesis.google.api.*"},
{"from": "google.type.*", "to": "thesis.google.type.*"},
{"from": "grpc.health.v1.*", "to": "thesis.grpc.health.v1.*"},
{"from": "grpc.reflection.v1.*", "to": "thesis.grpc.reflection.v1.*"},
{"from": "grpc.reflection.v1alpha.*", "to": "thesis.grpc.reflection.v1alpha.*"}
]
}
12 changes: 12 additions & 0 deletions src/Exception/InvalidNamespaceMapping.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Thesis\Protoc\Exception;

use Thesis\Protoc\ProtocException;

/**
* @api
*/
final class InvalidNamespaceMapping extends ProtocException {}
3 changes: 2 additions & 1 deletion src/Plugin/ClassLikeGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ public function __construct(
?string $package = null,
?string $syntax = null,
?Edition $edition = null,
Mapping\TypeMap $types = new Mapping\TypeMap(),
) {
$namespacer = new Generator\PhpNamespacer($namespace);
$namespacer = new Generator\PhpNamespacer($namespace, $types, $package);
$this->grpc = new Generator\GrpcGenerator(
$namespacer,
$graph,
Expand Down
42 changes: 15 additions & 27 deletions src/Plugin/Compiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
use Thesis\Protoc\Plugin\Generator\DescriptorMetadataRegistryGenerator;
use Thesis\Protoc\Plugin\Generator\FileFactory;
use Thesis\Protoc\Plugin\Generator\PhpNamespacer;
use Thesis\Protoc\Plugin\Parser\FileDescriptor;
use Thesis\Protoc\Plugin\Parser\MessageDescriptor;
use Thesis\Protoc\Plugin\Parser\ServiceMethodDescriptor;
use Thesis\Protoc\ProtocException;
Expand All @@ -29,8 +28,12 @@

private Parser $parser;

/**
* @param non-empty-string $mappingPath
*/
public function __construct(
private Encoder $encoder,
private string $mappingPath,
) {
$this->parser = new Parser();
}
Expand Down Expand Up @@ -58,16 +61,20 @@ private function doGenerate(
CodeGeneratorRequest $request,
CompilerOptions $options,
): iterable {
$types = Mapping\TypeMap::fromFile($this->mappingPath);

$request = $this->parser->parse($request);

$registry = new Dependency\Registry($request, $options);
$namespaces = new NamespaceResolver($types, $options);

$registry = new Dependency\Registry($request, $namespaces);

$descriptorPaths = new PathTable();

$registries = new DescriptorTable();

foreach ($request as $source => $proto) {
$phpNamespace = self::determinePhpNamespace($proto, $options);
$phpNamespace = $namespaces->file($proto) ?? throw self::namespaceCannotBeDetermined();

$index = new NameIndex();

Expand All @@ -78,12 +85,14 @@ private function doGenerate(
files: new FileFactory(
self::createClassLikeGeneratedDoc($request, $source),
$path,
$phpNamespace,
),
graph: $registry->graph($source),
index: $index,
package: $proto->package,
syntax: $proto->syntax,
edition: $proto->file->edition,
types: $types,
);

foreach ($proto->services as $service) {
Expand Down Expand Up @@ -185,30 +194,9 @@ private function doGenerateMessages(ClassLikeGenerator $generator, MessageDescri
}
}

/**
* @return non-empty-string
* @throws CodeCannotBeGenerated
*/
private static function determinePhpNamespace(
FileDescriptor $descriptor,
CompilerOptions $options,
): string {
if ($options->phpNamespace !== null) {
return $options->phpNamespace;
}

$phpNamespace = $descriptor->options?->phpNamespace;
if ($phpNamespace !== null && $phpNamespace !== '') {
return $phpNamespace;
}

$package = $descriptor->package;
if ($package !== null && $package !== '') {
/** @var non-empty-string */
return Naming::joinNamespace(explode('.', $package));
}

throw new CodeCannotBeGenerated('neither "package" nor "php_namespace" option was specified in the provided proto files, therefore I cannot determine the namespace under which the PHP files should be created.
private static function namespaceCannotBeDetermined(): CodeCannotBeGenerated
{
return new CodeCannotBeGenerated('neither "package" nor "php_namespace" option was specified in the provided proto files, therefore I cannot determine the namespace under which the PHP files should be created.
If you cannot modify the proto files, please pass the namespace via command-line arguments as follows:
--custom-plugin_out=php_namespace=App\\\Service\\\V1:path/to/generated');
}
Expand Down
18 changes: 12 additions & 6 deletions src/Plugin/Dependency/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Thesis\Protoc\Plugin\Dependency;

use Thesis\Protoc\Plugin\CompilerOptions;
use Thesis\Protoc\Plugin\NamespaceResolver;
use Thesis\Protoc\Plugin\Naming;
use Thesis\Protoc\Plugin\Parser;

Expand All @@ -22,9 +22,11 @@ final class Registry
/** @var array<string, list<Index>> */
private array $fileIndexes = [];

public function __construct(Parser\Request $request, CompilerOptions $options)
{
$this->createIndex($request, $options);
public function __construct(
Parser\Request $request,
private readonly NamespaceResolver $namespaces,
) {
$this->createIndex($request, $namespaces);
$this->mergeIndex($request);
$this->createDependencyGraph($request);
}
Expand All @@ -34,7 +36,7 @@ public function graph(string $name): Graph
return $this->graph[$name] ?? throw new \RuntimeException("Graph for file {$name} does not exist.");
}

private function createIndex(Parser\Request $request, CompilerOptions $options): void
private function createIndex(Parser\Request $request, NamespaceResolver $namespaces): void
{
foreach ($request->descriptors as $name => $descriptor) {
$types = iterator_to_array(
Expand All @@ -45,7 +47,7 @@ private function createIndex(Parser\Request $request, CompilerOptions $options):
);

$package = $descriptor->package ?? '.';
$namespace = $options->phpNamespace ?? $descriptor->options->phpNamespace ?? Naming::namespace($package);
$namespace = $namespaces->file($descriptor) ?? Naming::namespace($package);

$this->index[$name] = new Index(
$types,
Expand Down Expand Up @@ -192,6 +194,10 @@ private function extractTypes(array $typeNames, string $proto): iterable
$class = Naming::namespace(substr($typeName, \strlen($prefix)));
$fqcn = "\\{$index->namespace}\\{$class}";

// A type relocated by an exact rule leaves the namespace of its own
// package behind, so its name is resolved from the rule alone.
$fqcn = $this->namespaces->type($typeName) ?? $fqcn;

yield $typeName => new Type(
$fqcn,
$class,
Expand Down
29 changes: 28 additions & 1 deletion src/Plugin/Generator/FileFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@
{
private PsrPrinter $printer;

/**
* @param string $path directory the $namespace maps onto
* @param ?string $namespace namespace the file the code belongs to is generated into
*/
public function __construct(
private string $generatedDoc,
private string $path,
private ?string $namespace = null,
) {
$this->printer = new Printer()->setTypeResolving(false);
}
Expand Down Expand Up @@ -52,8 +57,30 @@ public function create(
};

return new CodeGeneratorResponse\File(
name: \sprintf('%s/%s.php', $this->path, $code instanceof PhpNamespace ? Naming::path($path) : $path),
name: $code instanceof PhpNamespace
? $this->resolvePath($code->getName(), $path)
: \sprintf('%s/%s.php', $this->path, $path),
content: $content,
);
}

/**
* Types normally live under the namespace of their file, so the path they are written
* to is the one configured for that file. A type moved elsewhere by a relocation rule
* no longer belongs there and is written under its own namespace instead.
*/
private function resolvePath(string $namespace, string $path): string
{
if ($this->namespace === null || $namespace === $this->namespace || str_starts_with($namespace, "{$this->namespace}\\")) {
return \sprintf('%s/%s.php', $this->path, Naming::path($path));
}

$paths = explode('.', $path);

return \sprintf(
'%s/%s.php',
str_replace('\\', '/', $namespace),
Naming::pascalCase($paths[\count($paths) - 1]),
);
}
}
46 changes: 42 additions & 4 deletions src/Plugin/Generator/PhpNamespacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Thesis\Protoc\Plugin\Generator;

use Nette\PhpGenerator\PhpNamespace;
use Thesis\Protoc\Plugin\Mapping\TypeMap;
use Thesis\Protoc\Plugin\Naming;

/**
Expand All @@ -14,21 +15,58 @@
{
public function __construct(
public string $namespace,
private TypeMap $types = new TypeMap(),
private ?string $package = null,
) {}

public function create(string $path): PhpNamespace
{
$namespace = $this->namespace;
return new PhpNamespace($this->resolve($path));
}

public function fqcn(string $path): string
{
$paths = explode('.', $path);
$name = $paths[\count($paths) - 1];

return Naming::joinNamespace([
'',
$this->resolve($path),
$name,
]);
}

private function resolve(string $path): string
{
$rewritten = $this->rewrite($path);

if ($rewritten !== null) {
$segments = explode('.', $rewritten);
array_pop($segments);

return Naming::joinNamespace($segments);
}

$paths = explode('.', $path);
$typeNamespace = \array_slice($paths, 0, \count($paths) - 1);

if (\count($typeNamespace) > 0) {
$namespace = Naming::joinNamespace([
$namespace,
return Naming::joinNamespace([
$this->namespace,
...$typeNamespace,
]);
}

return new PhpNamespace($namespace);
return $this->namespace;
}

/**
* @return ?non-empty-string
*/
private function rewrite(string $path): ?string
{
$type = $this->package !== null && $this->package !== '' ? "{$this->package}.{$path}" : $path;

return $this->types->rewriteType($type);
}
}
Loading