diff --git a/src/LaravelSchemaGeneratorServiceProvider.php b/src/LaravelSchemaGeneratorServiceProvider.php index 1a69918..a656a8e 100644 --- a/src/LaravelSchemaGeneratorServiceProvider.php +++ b/src/LaravelSchemaGeneratorServiceProvider.php @@ -23,7 +23,6 @@ use RomegaSoftware\LaravelSchemaGenerator\TypeHandlers\TypeHandlerRegistry; use RomegaSoftware\LaravelSchemaGenerator\TypeHandlers\UniversalTypeHandler; use RomegaSoftware\LaravelSchemaGenerator\Writers\ZodTypeScriptWriter; -use Spatie\LaravelData\Resolvers\DataMorphClassResolver; use Spatie\LaravelData\Resolvers\DataValidationMessagesAndAttributesResolver; use Spatie\LaravelData\Resolvers\DataValidationRulesResolver; use Spatie\LaravelData\Support\DataConfig; @@ -83,7 +82,7 @@ protected function registerCoreServices(): void $app->make(DataConfig::class), $app->make(RuleNormalizer::class), $app->make(RuleDenormalizer::class), - $app->make(DataMorphClassResolver::class), + $this->resolveDataMorphClassResolver($app), ); }); $this->app->singleton(DataValidationMessagesAndAttributesResolver::class, function ($app) { @@ -193,6 +192,24 @@ protected function spatieDataAvailable(): bool return class_exists(\Spatie\LaravelData\Resolvers\DataValidatorResolver::class); } + /** + * Resolve the legacy morph class resolver if the installed Spatie version requires it. + */ + protected function resolveDataMorphClassResolver($app): ?object + { + if (! InheritingDataValidationRulesResolver::requiresDataMorphClassResolver()) { + return null; + } + + $resolverClass = 'Spatie\\LaravelData\\Resolvers\\DataMorphClassResolver'; + + if (! class_exists($resolverClass)) { + return null; + } + + return $app->make($resolverClass); + } + /** * Bootstrap services */ diff --git a/src/Resolvers/InheritingDataValidationRulesResolver.php b/src/Resolvers/InheritingDataValidationRulesResolver.php index 489fd73..1fcb52e 100644 --- a/src/Resolvers/InheritingDataValidationRulesResolver.php +++ b/src/Resolvers/InheritingDataValidationRulesResolver.php @@ -3,8 +3,8 @@ namespace RomegaSoftware\LaravelSchemaGenerator\Resolvers; use ReflectionClass; +use ReflectionMethod; use RomegaSoftware\LaravelSchemaGenerator\Attributes\InheritValidationFrom; -use Spatie\LaravelData\Resolvers\DataMorphClassResolver; use Spatie\LaravelData\Resolvers\DataValidationRulesResolver as BaseResolver; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\Validation\DataRules; @@ -22,14 +22,23 @@ public function __construct( DataConfig $dataConfig, RuleNormalizer $ruleAttributesResolver, RuleDenormalizer $ruleDenormalizer, - DataMorphClassResolver $dataMorphClassResolver, + ?object $dataMorphClassResolver = null, ) { - parent::__construct( + $parameters = [ $dataConfig, $ruleAttributesResolver, $ruleDenormalizer, - $dataMorphClassResolver - ); + ]; + + if (self::requiresDataMorphClassResolver()) { + if ($dataMorphClassResolver === null) { + throw new \RuntimeException('Spatie Laravel Data morph resolver is required but was not provided.'); + } + + $parameters[] = $dataMorphClassResolver; + } + + parent::__construct(...$parameters); } public function execute( @@ -176,4 +185,16 @@ protected function mergeRuleSet(array $existing, array $incoming): array return $existing; } + + public static function requiresDataMorphClassResolver(): bool + { + static $requires; + + if ($requires === null) { + $constructor = new ReflectionMethod(BaseResolver::class, '__construct'); + $requires = $constructor->getNumberOfParameters() === 4; + } + + return $requires; + } } diff --git a/tests/Fixtures/Expected/unified-schemas.ts b/tests/Fixtures/Expected/unified-schemas.ts index 02f3576..ac087a5 100644 --- a/tests/Fixtures/Expected/unified-schemas.ts +++ b/tests/Fixtures/Expected/unified-schemas.ts @@ -10,7 +10,7 @@ export const UnifiedValidationRequestSchema = z.object({ export type UnifiedValidationRequestSchemaType = z.infer; export const UnifiedDataSchema = z.object({ - account_details: z.object({ name: z.string({ error: 'The account details.name field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.name field is required.' }).min(1, 'The account details.name field is required.').max(120, 'The account details.name field must not be greater than 120 characters.'), email: z.string().max(255, 'The account details.email field must not be greater than 255 characters.').trim().nullable().optional(), timezone: z.string().max(50, 'The account details.timezone field must not be greater than 50 characters.').trim().nullable().optional(), status: z.enum(["pending", "active", "inactive", "deleted"], { message: "The account details.status field is required." }), priority: z.number({ error: 'The account details.priority field must be an integer.' }).refine((val) => val != undefined && val != null, { error: 'The account details.priority field is required.' }).min(1, 'The account details.priority field must be at least 1.').max(10, 'The account details.priority field must not be greater than 10.'), address: z.object({ street: z.string({ error: 'The account details.address.street field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.street field is required.' }).min(1, 'The account details.address.street field is required.').max(120, 'The account details.address.street field must not be greater than 120 characters.'), city: z.string({ error: 'The account details.address.city field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.city field is required.' }).min(1, 'The account details.address.city field is required.').max(80, 'The account details.address.city field must not be greater than 80 characters.'), state: z.string({ error: 'The account details.address.state field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.state field is required.' }).min(1, 'The account details.address.state field is required.').max(2, 'The account details.address.state field must not be greater than 2 characters.'), postal_code: z.string({ error: 'The account details.address.postal code field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.postal code field is required.' }).min(1, 'The account details.address.postal code field is required.').regex(/^[0-9]{5}$/, 'The account details.address.postal code field format is invalid.'), country: z.string({ error: 'The account details.address.country field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.country field is required.' }).min(1, 'The account details.address.country field is required.').length(2, 'The account details.address.country field must be 2 characters.') }), preferences: z.object({ marketing_opt_in: z.preprocess((val) => { if (typeof val === "string") { const normalized = val.toLowerCase(); if (normalized === "true" || normalized === "1" || normalized === "on" || normalized === "yes") { return true; } if (normalized === "false" || normalized === "0" || normalized === "off" || normalized === "no") { return false; } } if (val === 1) { return true; } if (val === 0) { return false; } return val; }, z.boolean().optional()), contacts: z.array(z.object({ label: z.enum(["primary", "backup"], { message: "The account details.preferences.contacts.*.label field is required." }), email: z.email({ error: 'The account details.preferences.contacts.*.email field must be a valid email address.' }).trim().min(1, 'The account details.preferences.contacts.*.email field is required.'), phone: z.string().max(20, 'The account details.preferences.contacts.*.phone field must not be greater than 20 characters.').trim().nullable().optional() })).nullable() }).nullable().optional() }), + account_details: z.object({ name: z.string({ error: 'The account details.name field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.name field is required.' }).min(1, 'The account details.name field is required.').max(120, 'The account details.name field must not be greater than 120 characters.'), email: z.string().max(255, 'The account details.email field must not be greater than 255 characters.').trim().nullable().optional(), timezone: z.string().max(50, 'The account details.timezone field must not be greater than 50 characters.').trim().nullable().optional(), status: z.enum(["pending", "active", "inactive", "deleted"], { message: "The account details.status field is required." }), priority: z.number({ error: 'The account details.priority field must be an integer.' }).refine((val) => val != undefined && val != null, { error: 'The account details.priority field is required.' }).min(1, 'The account details.priority field must be at least 1.').max(10, 'The account details.priority field must not be greater than 10.'), address: z.object({ street: z.string({ error: 'The account details.address.street field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.street field is required.' }).min(1, 'The account details.address.street field is required.').max(120, 'The account details.address.street field must not be greater than 120 characters.'), city: z.string({ error: 'The account details.address.city field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.city field is required.' }).min(1, 'The account details.address.city field is required.').max(80, 'The account details.address.city field must not be greater than 80 characters.'), state: z.string({ error: 'The account details.address.state field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.state field is required.' }).min(1, 'The account details.address.state field is required.').max(2, 'The account details.address.state field must not be greater than 2 characters.'), postal_code: z.string({ error: 'The account details.address.postal code field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.postal code field is required.' }).min(1, 'The account details.address.postal code field is required.').regex(/^[0-9]{5}$/, 'The account details.address.postal code field format is invalid.'), country: z.string({ error: 'The account details.address.country field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The account details.address.country field is required.' }).min(1, 'The account details.address.country field is required.').length(2, 'The account details.address.country field must be 2 characters.') }), preferences: z.object({ marketing_opt_in: z.preprocess((val) => { if (typeof val === "string") { const normalized = val.toLowerCase(); if (normalized === "true" || normalized === "1" || normalized === "on" || normalized === "yes") { return true; } if (normalized === "false" || normalized === "0" || normalized === "off" || normalized === "no") { return false; } } if (val === 1) { return true; } if (val === 0) { return false; } return val; }, z.boolean().optional()), contacts: z.array(z.object({ label: z.enum(["primary", "backup"], { message: "The account details.preferences.contacts.*.label field is required." }), email: z.email({ error: 'The account details.preferences.contacts.*.email field must be a valid email address.' }).trim().min(1, 'The account details.preferences.contacts.*.email field is required.'), phone: z.string().max(20, 'The account details.preferences.contacts.*.phone field must not be greater than 20 characters.').trim().nullable().optional() })).nullable().optional() }).nullable().optional() }), projects: z.array(z.object({ title: z.string({ error: 'The projects.*.title field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The projects.*.title field is required.' }).min(1, 'The projects.*.title field is required.').max(150, 'The projects.*.title field must not be greater than 150 characters.'), summary: z.string().max(500, 'The projects.*.summary field must not be greater than 500 characters.').trim().nullable().optional(), status_state: z.enum(["pending", "active", "inactive", "deleted"], { message: "The projects.*.status state field is required." }), metrics: z.array(z.object({ key: z.string({ error: 'The projects.*.metrics.*.key field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The projects.*.metrics.*.key field is required.' }).min(1, 'The projects.*.metrics.*.key field is required.'), value: z.number({ error: 'The projects.*.metrics.*.value field is required.' }).refine((val) => val != undefined && val != null, { error: 'The projects.*.metrics.*.value field is required.' }).min(0, 'The projects.*.metrics.*.value field must be at least 0.'), trend: z.enum(["up", "down", "flat"]).nullable().optional() })).min(1, 'The projects.*.metrics field must have at least 1 items.'), schedule: z.object({ starts_at: z.string({ error: 'The projects.*.schedule.*.starts at field is required.' }).trim().refine((val) => val != undefined && val != null && val != '', { error: 'The projects.*.schedule.*.starts at field is required.' }).min(1, 'The projects.*.schedule.*.starts at field is required.').refine((val) => { if (val === undefined || val === null) { return true; } if (typeof val !== "string") { return false; } const timestamp = Date.parse(val); return !Number.isNaN(timestamp); }, { message: 'The projects.*.schedule.*.starts at field must be a valid date.' }), ends_at: z.string().refine((val) => { if (val === undefined || val === null) { return true; } if (typeof val !== "string") { return false; } const timestamp = Date.parse(val); return !Number.isNaN(timestamp); }, { message: 'The projects.*.schedule.*.ends at field must be a valid date.' }).trim().nullable().optional() }).optional() })).min(1, 'The projects field must have at least 1 items.'), notes: z.string().max(500, 'The notes field must not be greater than 500 characters.').trim().nullable().optional(), });